From 69b2703d7b5dc1cb185f18770d050c4f51a3565b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 16:02:07 -0400 Subject: [PATCH 001/141] Charge in-unit non-dependants in the local Council Tax Reduction schemes The five English working-age local schemes (Merton, Kingston upon Thames, Newham, Westminster, Oxford) reproduce the Default Scheme's para 9: an adult in the applicant's benefit unit who is not the claimant, partner, child or young person is a non-dependant. local_non_dep_deductions now charges the claiming family the household pool (claims x pool x share, as the national variable), counting the higher of each non-dependant couple (each member in Merton and Kingston where the couple has UC) and every other adult separately. normal_gross_income_non_dep_deduction bands a claimant/partner on the couple's joint income and anyone else on their own, uses each person's own hours, and gates the IS/JSA/ESA/PC/UC exemptions to the award's claimant and partner. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2014-local.fixed.md | 1 + .../local_scheme_non_dependants.yaml | 675 ++++++++++++++++++ .../council_tax_reduction/_legacy.py | 79 +- 3 files changed, 726 insertions(+), 29 deletions(-) create mode 100644 changelog.d/2014-local.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml diff --git a/changelog.d/2014-local.fixed.md b/changelog.d/2014-local.fixed.md new file mode 100644 index 0000000000..129c493722 --- /dev/null +++ b/changelog.d/2014-local.fixed.md @@ -0,0 +1 @@ +Charge non-dependant deductions in the English working-age local Council Tax Reduction schemes (Merton, Kingston upon Thames, Newham, Westminster, Oxford) for adults in the applicant's own benefit unit, deduct separately for each adult in another family who is not its claimant or partner, band each on their own income and hours, and apply benefit-receipt exemptions only to the award's claimant and partner. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml new file mode 100644 index 0000000000..fbefab0fd2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -0,0 +1,675 @@ +# Non-dependant deductions in the English working-age local schemes. +# +# Each council's scheme reproduces the Default Scheme's definition: a +# non-dependant is anyone who normally resides with the applicant other than +# a member of the applicant's family (a partner, or a child or qualifying young +# person the applicant is responsible for), a joint occupier, a commercial +# lodger or a paid carer (para 9; Oxford paras 42 and Annex 1). So an adult in +# the applicant's own benefit unit who is not the claimant, partner, child or +# young person is a non-dependant, and so is each adult in another family. +# Each non-dependant aged 18 or over is banded on their own normal gross weekly +# income and is in remunerative work on their own hours (para 30(1)-(2) and +# para 10); a couple's income is joint and the couple pays one deduction, the +# higher (para 30(3)-(4); Oxford para 44), except that Merton and Kingston +# upon Thames deduct for each member of a couple with a Universal Credit +# award. The benefit-receipt exemptions (para 30(8)) apply to the person on +# the award: a family's award is its claimant's and partner's. +# +# Weekly amounts for 2026-27 (52 weeks a year): +# - Merton (para 30) and Westminster (Appendix A), Oxford (para 43): £5.20 not +# in remunerative work or under £279; £10.60 from £279; £13.30 from £485; +# £15.95 from £605. +# - Kingston upon Thames (para 30A): £5.64; £11.50 from £279; £14.43 from +# £485; £17.31 from £605. +# - Newham (para 30B): £7.54; £14.96 from £183; £18.84 from £316; £22.61 from +# £394. +# Earnings of £30,000 are £576.92 a week, £20,000 are £384.62 and £12,000 are +# £230.77. The claimant has no income and no capital, so Merton, Kingston +# upon Thames, Westminster and Oxford support 100% of the £1,800 council tax +# and Newham 70% (£1,260), less the deductions. +# +# The claimant and partner flags are set because the model's inference treats +# an adult child as a lone parent's partner. + +- name: Merton charges the applicant's own non-dependant son for his own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + # £13.30 a week on £576.92. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60] + merton_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Merton deducts separately for a lone parent and her working son in another family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # The mother is not in remunerative work (£5.20) and is not banded on + # her son's earnings; the son pays £13.30 on his own. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] + merton_council_tax_reduction_non_dep_deductions: [962, 0] + council_tax_reduction: 838 + +- name: Merton exempts a non-dependant on Income Support but not the adult son in her benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + income_support: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(8)(a): the Income Support is the mother's, not her son's. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + council_tax_reduction: 1_108.40 + +- name: Merton exempts a Universal Credit non-dependant without earnings but not her earning son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + universal_credit: 1_000 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(8)(c): the mother's award is calculated on no earned income, as + # her son's earnings are not hers; he is not on her award. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: Merton deducts for each member of a Universal Credit couple on their own hours + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(3): no single deduction where there is a UC award. The worker + # pays £13.30 on the couple's joint £576.92; the partner, not in + # remunerative work, £5.20. The joint award has earned income, so + # neither is exempt under para 30(8)(c). + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 270.40] + merton_council_tax_reduction_non_dep_deductions: [962, 0] + council_tax_reduction: 838 + +- name: Merton makes one deduction for a couple not on Universal Credit, on joint income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + first: + age: 35 + is_claimant_or_partner: true + employment_income: 15_000 + weekly_hours: 37.5 + second: + age: 35 + is_claimant_or_partner: true + employment_income: 15_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [first, second] + would_claim_uc: false + households: + household: + members: [claimant, first, second] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(4): £288.46 each, £576.92 jointly: £13.30, deducted once. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 691.60] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 + +- name: Merton makes no deduction for the son of an applicant who receives PIP daily living + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + pip_dl: 1_000 + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(6)(b)(iii). + merton_council_tax_reduction_non_dep_deductions: 0 + council_tax_reduction: 1_800 + +- name: Kingston upon Thames charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 0 + output: + # Para 30A: £14.43 a week on £576.92. + kingston_upon_thames_council_tax_reduction_non_dep_deductions: 750.36 + council_tax_reduction: 1_049.64 + +- name: Kingston upon Thames deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 0 + output: + # Mother £5.64; son £11.50 on £384.62. + kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction: [0, 293.28, 598] + council_tax_reduction: 908.72 + +- name: Newham charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Para 30B: £22.61 a week on £576.92; 70% of £1,800 is £1,260. + newham_council_tax_reduction_non_dep_deductions: 1_175.72 + council_tax_reduction: 84.28 + +- name: Newham deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 12_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Mother £7.54; son £14.96 on £230.77. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 392.08, 777.92] + council_tax_reduction: 90 + +- name: Newham makes one deduction for a Universal Credit couple, the working member's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Para 30B(3) has no Universal Credit exception: the higher of £22.61 + # and £7.54. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 1_175.72, 392.08] + newham_council_tax_reduction_non_dep_deductions: [1_175.72, 0] + council_tax_reduction: 84.28 + +- name: Westminster charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + westminster_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Westminster makes no deduction for a full-time student son in the applicant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + in_HE: true + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + westminster_council_tax_reduction_non_dep_deductions: 0 + council_tax_reduction: 1_800 + +- name: Westminster exempts a non-dependant on income-based JSA but not the adult son in her benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + jsa_income: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + westminster_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + council_tax_reduction: 1_108.40 + +- name: Oxford charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Para 43: £13.30 a week on £576.92; the claimant's own income band is + # the 100% band. + oxford_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Oxford deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Mother £5.20; son £10.60 on £384.62. + oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 551.20] + council_tax_reduction: 978.40 + +- name: Oxford keeps deducting for a working-age non-dependant on Income Support + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + income_support: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Para 47: the benefit-receipt exemptions are for pensioner applicants + # only. + oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] + council_tax_reduction: 838 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 fd35957f53..300cc583e1 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 @@ -11,8 +11,7 @@ def in_other_non_liable_family(person, period): liable or paying the claimant. It is a proxy: a family jointly liable for the council tax but paying no rent is wrongly included, which #2009's claimant-family test fixes. The eligibility for a non-dependant deduction - and the local schemes' aggregation both use this, so they stay on one - definition.""" + uses this, for the national and local schemes alike.""" return ~person.benunit("benunit_contains_household_head", period) & ~person.benunit( "benunit_is_rent_liable", period ) @@ -88,31 +87,41 @@ def local_non_dep_deductions( individual_deduction_variable, one_deduction_for_uc_couples=True, ): - # The local schemes still charge only the non-dependants of other - # families. Non-dependants within a family's own benefit unit, now - # eligible for the national schemes, are left out here until each - # council's aggregation is brought into line. + # Every eligible person in the household is a non-dependant of each + # claiming family: the claimant's own family has only its benefit-unit + # non-dependants, and jointly liable sharers are not non-dependants (each + # scheme's para 9, the Default Scheme's wording). person = benunit.members - deductions = person(individual_deduction_variable, period) * ( - in_other_non_liable_family(person, period) - ) - deduction_for_benunit = benunit.max(deductions) + deductions = person(individual_deduction_variable, period) + # Only one deduction for a couple, the higher (para 30(3) of each scheme; + # Oxford para 44), except that Merton's and Kingston upon Thames's + # schemes deduct for each member of a couple with a Universal Credit + # award. Any other adult in the family is a non-dependant in their own + # right. + claimant_or_partner = person("is_claimant_or_partner", period) + higher_of_couple = ( + person.get_rank(person.benunit, -deductions, condition=claimant_or_partner) == 0 + ) + each_member_deducted = False if not one_deduction_for_uc_couples: - has_uc = benunit("universal_credit", period) > 0 - deduction_for_benunit = where( - has_uc, - benunit.sum(deductions), - deduction_for_benunit, - ) - is_benunit_head = benunit.members("is_benunit_head", period) - deductions_to_count = is_benunit_head * benunit.project(deduction_for_benunit) - deductions_in_household = benunit.max( - benunit.members.household.sum(deductions_to_count) + each_member_deducted = person.benunit("universal_credit", period) > 0 + counted_member = where( + claimant_or_partner, higher_of_couple | each_member_deducted, True + ) + counted = deductions * counted_member + # Members of the applicant's own family are not its non-dependants (para + # 9(2)(a)), as in council_tax_reduction_non_dep_deductions. + own_family_member = ~person( + "is_benefit_unit_non_dependant_for_legacy_benefits", period + ) + non_dependants = benunit.max(person.household.sum(counted)) - benunit.sum( + counted * own_family_member ) # 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 + claims = benunit("council_tax_reduction_claimant_benunit", period) + return claims * share * non_dependants def normal_gross_income_non_dep_deduction( @@ -138,29 +147,41 @@ def normal_gross_income_non_dep_deduction( ] gross_income = add(person, period, gross_income_components) earned_income = add(person, period, earned_income_components) - weekly_benunit_gross_income = person.benunit.sum(gross_income) / WEEKS_IN_YEAR - weekly_benunit_earned_income = person.benunit.sum(earned_income) / WEEKS_IN_YEAR - benunit_weekly_hours = person.benunit.max(person("weekly_hours", period)) + # A couple's joint income (para 30(4) of each scheme; Oxford para 44); + # another adult in the family is a non-dependant in their own right, on + # their own income. + claimant_or_partner = person("is_claimant_or_partner", period) + couple_gross_income = person.benunit.sum(gross_income * claimant_or_partner) + weekly_gross_income = ( + where(claimant_or_partner, couple_gross_income, gross_income) / WEEKS_IN_YEAR + ) + # Remunerative work is each person's own (para 10 and para 30(1)(a)). A + # couple paying one deduction pays the higher, so the working member's + # band on their joint income. in_remunerative_work = ( - benunit_weekly_hours >= ctr.non_dep_deduction.remunerative_work_hours + person("weekly_hours", period) >= ctr.non_dep_deduction.remunerative_work_hours ) weekly_deduction = where( in_remunerative_work, - ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), + ctr.non_dep_deduction.amount.calc(weekly_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 = ( + # Benefit-unit awards are the claimant's and partner's: another adult in + # the unit is not on them. + income_based_benefit = claimant_or_partner & ( (person.benunit("income_support", period) > 0) | (person.benunit("jsa_income", period) > 0) | (person.benunit("esa_income", period) > 0) | (person.benunit("pension_credit", period) > 0) ) - has_uc = person.benunit("universal_credit", period) > 0 - no_earned_income = weekly_benunit_earned_income <= 0 + has_uc = claimant_or_partner & (person.benunit("universal_credit", period) > 0) + # The award is the couple's, so it is calculated on their joint earned + # income. + no_earned_income = person.benunit.sum(earned_income * claimant_or_partner) <= 0 exempt = ( claimant_exempt | full_time_student From ab4022bf3d4b26d0e7379b82b9fca7f3e1e67f96 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 15:31:28 -0400 Subject: [PATCH 002/141] Apply the Pension Credit earnings disregards (SPC Regs 2002 Sch VI) 20 pounds a week for a lone parent (para 1), a carer satisfying Sch I para 4 (para 3), or a claimant or partner on a listed disability benefit or certified blind (para 4(1)); otherwise 5 pounds single and 10 pounds couple (para 5); at most 20 pounds (para 4A), never more than the earnings. Refs #1954. Co-Authored-By: Claude Opus 5.5 --- changelog.d/pc-earnings-disregard.fixed.md | 1 + .../earnings_disregard/higher/amount.yaml | 10 + .../disability_benefit_unit_benefits.yaml | 19 ++ .../higher/disability_benefits.yaml | 44 +++ .../earnings_disregard/standard.yaml | 20 ++ .../pension_credit_earnings_disregard.yaml | 250 ++++++++++++++++++ .../pension_credit/pension_credit_income.yaml | 6 +- ...on_credit_earnings_disregard_properties.py | 136 ++++++++++ .../pension_credit_earnings_disregard.py | 55 ++++ .../pension_credit/pension_credit_income.py | 4 +- 10 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 changelog.d/pc-earnings-disregard.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml create mode 100644 policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py diff --git a/changelog.d/pc-earnings-disregard.fixed.md b/changelog.d/pc-earnings-disregard.fixed.md new file mode 100644 index 0000000000..8a95e17dbe --- /dev/null +++ b/changelog.d/pc-earnings-disregard.fixed.md @@ -0,0 +1 @@ +Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at earnings. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml new file mode 100644 index 0000000000..bc51f73542 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml @@ -0,0 +1,10 @@ +description: Weekly earnings disregarded from Pension Credit income for a lone parent, a carer, or a claimant or partner on a listed disability benefit or certified blind. It is the most that can be disregarded however many conditions are met. +values: + 2003-10-06: 20 +metadata: + unit: currency-GBP + period: week + label: Pension Credit higher earnings disregard + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI paras. 1, 3, 4 and 4A + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml new file mode 100644 index 0000000000..8e31a28658 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml @@ -0,0 +1,19 @@ +description: > + Benefit-unit awards whose receipt qualifies the benefit unit for the higher + Pension Credit earnings disregard (Sch. VI para. 4(1)(a)(vii), employment + and support allowance, from 27 October 2008). The model holds income-related + employment and support allowance on the benefit unit, as the claimant's + award; contributory employment and support allowance is a person-level + benefit in `disability_benefits`. +metadata: + label: Pension Credit higher earnings disregard benefit-unit disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a)(vii) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 +values: + 2003-10-06: [] + 2008-10-27: + - esa_income diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml new file mode 100644 index 0000000000..30a23dc061 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -0,0 +1,44 @@ +description: > + Person-level benefits whose receipt by the claimant or partner qualifies the + benefit unit for the higher Pension Credit earnings disregard (Sch. VI para. + 4(1)(a)). Income-related employment and support allowance, a benefit-unit + award, is in `disability_benefit_unit_benefits`. + `incapacity_benefit` stands for long-term incapacity benefit (para. + 4(1)(a)(i)); the model does not separate the short-term rates. Not modelled: + the war pensions mobility supplement (v), the working tax credit disability + elements (vi), pension age disability payment (iiia, from 21 October 2024), + Scottish adult disability living allowance (iva, from 21 March 2025) and + adult disability payment (viiia, from 21 March 2022), which have no separate + variables. +metadata: + label: Pension Credit higher earnings disregard disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) (ESA, from 27 October 2008) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 + - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 27(8)(b) (PIP, from 8 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/27 + - title: The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/591/schedule/paragraph/23 +values: + 2003-10-06: + - incapacity_benefit + - sda + - attendance_allowance + - dla + 2008-10-27: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + 2013-04-08: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + - pip + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml new file mode 100644 index 0000000000..80ffae79fe --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml @@ -0,0 +1,20 @@ +description: Weekly earnings disregarded from Pension Credit income where neither the claimant nor a partner qualifies for the higher disregard. +SINGLE: + values: + 2003-10-06: 5 + metadata: + label: Pension Credit standard earnings disregard (single) +COUPLE: + values: + 2003-10-06: 10 + metadata: + label: Pension Credit standard earnings disregard (couple) +metadata: + unit: currency-GBP + period: week + propagate_metadata_to_children: true + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 5 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/5 + - title: The State Pension Credit Regulations 2002 reg. 17(9) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/17 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml new file mode 100644 index 0000000000..2f09ea9468 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -0,0 +1,250 @@ +# Expected values are hand-computed from SPC Regs 2002 Sch VI (paras 1, 3, 4, +# 4A and 5): weekly amounts times 52 weeks, capped at the earnings. + +- name: A single claimant with earnings has 5 pounds a week disregarded (para 5(a)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: A couple with earnings has 10 pounds a week disregarded (para 5(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: A claimant on attendance allowance has 20 pounds a week disregarded (para 4(1)(a)(iii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A partner's personal independence payment qualifies the couple (para 4(1)(a)(viii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + pip_dl: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A carer satisfying Sch I para 4 has 20 pounds a week disregarded (para 3). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A lone parent has 20 pounds a week disregarded (para 1). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + child: + age: 10 + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A claimant certified blind has 20 pounds a week disregarded (para 4(1)(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_blind: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: 20 pounds is the most disregarded when both partners qualify (para 4A). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + partner: + age: 70 + employment_income: 2000 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard never exceeds the earnings. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 100 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 100 + +- name: Self-employed earnings get the same disregard. + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Without earnings there is nothing to disregard. + period: 2024 + input: + people: + claimant: + age: 70 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 0 + +- name: A dependant child's disability benefit does not qualify a couple for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + child: + age: 10 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: Income-related ESA, a benefit-unit award, qualifies for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + esa_income: 1000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard comes off Pension Credit income and raises the guarantee credit. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + state_pension: 9000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # minimum guarantee 218.15 a week in 2024-25 (SI 2024/242) + pension_credit_income: 9_000 + 2_000 - 5 * 52 + guarantee_credit: 218.15 * 52 - (9_000 + 2_000 - 5 * 52) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index b6cf5c9493..3c1b7febc4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -20,7 +20,11 @@ savings: 11_000 output: pension_credit_deemed_income: 104 - pension_credit_income: 343 + # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI para 5 + # disregard: 5 pounds a week for a single claimant, capped at the 129 of + # earnings. + pension_credit_earnings_disregard: 129 + pension_credit_income: 214 - name: Interest and dividends are not counted directly in Pension Credit income. period: 2021 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py new file mode 100644 index 0000000000..2aaa136839 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -0,0 +1,136 @@ +"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). + +Invariants, over generated pension-age benefit units: + +1. Statute: the disregard equals min(earnings, 52 x w), where w is 20 if the + unit has a lone parent, a carer satisfying Sch I para 4, or a claimant or + partner on a listed disability benefit or certified blind, and otherwise 5 + for a single claimant and 10 for a couple. The expected value is computed + here from the inputs, not read from the model. +2. Bounds: 0 <= disregard <= min(earnings, 20 x 52). +3. Differential: Pension Credit income is the pre-change income (sources less + tax, NI and half of pension contributions, floored at 0, recomputed from + the model's own components) less the disregard, floored at 0. +4. Monotonicity: the guarantee credit never falls when the disregard applies, + i.e. it is at least the guarantee credit without it. +5. The dated benefit lists follow the amending instruments: ESA from 27 + October 2008, PIP and AFIP from 8 April 2013. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import WEEKS_IN_YEAR + +YEAR = 2024 +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DISABILITY = [ + "attendance_allowance", + "dla_sc", + "pip_dl", + "incapacity_benefit", + "esa_contrib", +] + + +@st.composite +def units(draw): + couple = draw(st.booleans()) + child = draw(st.booleans()) and not couple + people = {} + names = ["claimant"] + (["partner"] if couple else []) + for n in names: + person = { + "age": draw(st.sampled_from([67, 72, 85])), + "employment_income": draw(st.sampled_from([0, 50, 400, 3_000])), + "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), + "state_pension": draw(st.integers(min_value=0, max_value=12_000)), + "private_pension_income": draw(st.sampled_from([0, 2_000])), + "is_blind": draw(st.sampled_from([False, False, False, True])), + "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), + } + benefit = draw(st.sampled_from([None, None] + DISABILITY)) + if benefit: + person[benefit] = 2_000 + people[n] = person + if child: + people["child"] = {"age": 10, "dla_sc": draw(st.sampled_from([0, 2_000]))} + return people + + +def simulate(people): + members = list(people) + dated = { + name: {k: {str(YEAR): v} for k, v in person.items()} + for name, person in people.items() + } + return Simulation( + situation={ + "people": dated, + "benunits": {"b": {"members": members}}, + "households": {"h": {"members": members}}, + } + ) + + +@SETTINGS +@given(units()) +def test_statute_bounds_differential_and_monotonicity(people): + sim = simulate(people) + calc = lambda v: float(sim.calculate(v, YEAR)[0]) + adults = [p for n, p in people.items() if n != "child"] + earnings = sum(p["employment_income"] + p["self_employment_income"] for p in adults) + couple = len(adults) == 2 + disabled = any( + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adults + ) + carer = calc("carer_minimum_guarantee_addition") > 0 + lone_parent = "child" in people + weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) + expected = min(earnings, weekly * WEEKS_IN_YEAR) + disregard = calc("pension_credit_earnings_disregard") + assert np.isclose(disregard, expected, atol=0.01) + assert 0 <= disregard <= min(earnings, 20 * WEEKS_IN_YEAR) + 0.01 + + sources = sim.tax_benefit_system.parameters( + f"{YEAR}-06-01" + ).gov.dwp.pension_credit.guarantee_credit.income + total = sum(float(sim.calculate(s, YEAR, map_to="benunit")[0]) for s in sources) + deductions = ( + float(sim.calculate("income_tax", YEAR, map_to="benunit")[0]) + + float(sim.calculate("national_insurance", YEAR, map_to="benunit")[0]) + + 0.5 * float(sim.calculate("pension_contributions", YEAR, map_to="benunit")[0]) + ) + before = max(0.0, total - deductions) + assert np.isclose( + calc("pension_credit_income"), max(0.0, before - disregard), atol=0.01 + ) + gc_without = max(0.0, calc("minimum_guarantee") - before) + assert calc("guarantee_credit") >= gc_without - 0.01 + + +def test_benefit_lists_follow_amending_instruments(): + from policyengine_uk.system import system + + def lists(date): + p = system.parameters(date).gov.dwp.pension_credit.earnings_disregard.higher + return set(p.disability_benefits), set(p.disability_benefit_unit_benefits) + + person, unit = lists("2008-10-26") + assert "esa_contrib" not in person and "esa_income" not in unit + person, unit = lists("2008-10-27") + assert "esa_contrib" in person and "esa_income" in unit + person, _ = lists("2013-04-07") + assert "pip" not in person and "armed_forces_independence_payment" not in person + person, _ = lists("2013-04-08") + assert {"pip", "armed_forces_independence_payment"} <= person + for date in ["2008-10-26", "2013-04-08", "2026-04-06"]: + person, _ = lists(date) + assert {"incapacity_benefit", "sda", "attendance_allowance", "dla"} <= person diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py new file mode 100644 index 0000000000..5b93defa35 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class pension_credit_earnings_disregard(Variable): + label = "Pension Credit earnings disregard" + documentation = ( + "Earnings disregarded from Pension Credit income under Schedule VI to " + "the State Pension Credit Regulations 2002: 20 pounds a week for a " + "lone parent (para. 1), where the claimant or partner is a carer " + "satisfying Sch. I para. 4 (para. 3), or where the claimant or partner " + "receives a listed disability benefit or is certified blind " + "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " + "pounds for a couple (para. 5). 20 pounds is the most disregarded " + "however many conditions are met (para. 4A), and the disregard never " + "exceeds the earnings. Not modelled: the employment-specific 20 pound " + "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " + "coastguards, lifeboat crew, reserve forces) and the transitional " + "protections of para. 4(2) to (4)." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.pension_credit.earnings_disregard + person = benunit.members + claimant_or_partner = person("is_uc_claimant", period) + on_disability_benefit = ( + add(person, period, p.higher.disability_benefits) > 0 + ) | person("is_blind", period) + unit_disability_benefits = p.higher.disability_benefit_unit_benefits + unit_on_disability_benefit = ( + add(benunit, period, unit_disability_benefits) > 0 + if len(unit_disability_benefits) > 0 + else False + ) + disabled = ( + benunit.any(claimant_or_partner & on_disability_benefit) + | unit_on_disability_benefit + ) + carer = benunit("carer_minimum_guarantee_addition", period) > 0 + lone_parent = benunit("is_lone_parent", period) + relation_type = benunit("relation_type", period) + weekly = where( + lone_parent | carer | disabled, + p.higher.amount, + p.standard[relation_type], + ) + earnings = max_(0, benunit("pension_credit_earnings", period)) + return min_(weekly * WEEKS_IN_YEAR, earnings) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index 1160800f3a..7a9ec44199 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -46,4 +46,6 @@ def formula(benunit, period, parameters): ) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate - return max_(0, total - deductions) + # Schedule VI earnings disregards (reg. 17(9)). + earnings_disregard = benunit("pension_credit_earnings_disregard", period) + return max_(0, total - deductions - earnings_disregard) From 570f163cea7be2de132171b984c7eff91632c9c0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:04:52 -0400 Subject: [PATCH 003/141] Test the local schemes' non-dependants against an oracle and each council's scale Hypothesis properties 6 and 7: each council's individual and family deductions equal an independent calculation from its 2026-27 scale, and an adult who is not a claimant or partner changes no one else's deduction through their earnings (both fail on the previous aggregation). A Merton case for a 19-year-old who has left education, and #2017's Newham case now apportions the head's own son between the jointly liable families (para 30(5)). Co-Authored-By: Claude Opus 5.5 --- .../family/benefit_unit_non_dependants.yaml | 13 +- .../local_scheme_non_dependants.yaml | 35 ++ ...t_benefit_unit_non_dependant_properties.py | 346 ++++++++++++++++++ 3 files changed, 388 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index ffa7cbe70f..c1aec4c786 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -726,10 +726,11 @@ council_tax_reduction_individual_non_dep_deduction_eligible: [false, true, true] council_tax_reduction_non_dep_deductions: [300, 0] -# The English working-age local schemes keep their own aggregation, which -# charges only other families' non-dependants. Widening the shared -# eligibility must not make a jointly liable sharer bear the head's own son. -- name: Local CTR (Newham), a jointly liable sharer does not bear the head's own in-unit son +# The English working-age local schemes reproduce the Default Scheme's para 9 +# and para 30(5): the head's own son normally resides with both jointly liable +# people, so each bears half his deduction (Newham para 30B: £18.84 a week on +# £384.62, £979.68 a year). 70% of each half of the £1,800 is £630. +- name: Local CTR (Newham), jointly liable families share the deduction for the head's own in-unit son period: 2025 absolute_error_margin: 0.01 input: @@ -764,8 +765,8 @@ council_tax: 1_800 output: council_tax_reduction_joint_liability_share: [0.5, 0.5] - newham_council_tax_reduction_non_dep_deductions: [0, 0] - newham_council_tax_reduction: [630, 630] + newham_council_tax_reduction_non_dep_deductions: [489.84, 489.84] + newham_council_tax_reduction: [140.16, 140.16] # SI 2012/2885 reg 9(2)(a): the applicant's own family are not its # non-dependants, so an individual deduction set for the applicant (here as diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml index fbefab0fd2..492c43583c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -64,6 +64,41 @@ merton_council_tax_reduction_non_dep_deductions: 691.60 council_tax_reduction: 1_108.40 +- name: Merton charges a 19-year-old who has left education in the applicant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + daughter: + # Left education, so not a qualifying young person or a student (the + # education input otherwise defaults to tertiary at 19). + age: 19 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + employment_income: 12_000 + weekly_hours: 30 + benunits: + benunit: + members: [parent, daughter] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, daughter] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + # In remunerative work on £230.77 a week, below £279: £5.20. + merton_council_tax_reduction_non_dep_deductions: 270.40 + council_tax_reduction: 1_529.60 + - name: Merton deducts separately for a lone parent and her working son in another family period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index 45a5ab4714..f39fd40735 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -27,6 +27,17 @@ 5. Bedrooms: every Universal Credit non-dependant within a benefit unit has a bedroom in the size criteria (para 10(1)(c)), so the deduction and the bedroom use one definition. +6. Local schemes: in Merton, Kingston upon Thames, Newham, Westminster and + Oxford, each non-dependant's deduction and each claiming family's total + equal an independent calculation from the council's scale: a couple banded + on joint earnings and deducted once at the higher amount (each member in + Merton and Kingston upon Thames when the couple has Universal Credit), any + other adult banded on their own earnings and hours, the benefit-receipt + exemptions only for a claimant or partner on the award (none in Oxford), + and the pool charged to each claiming family by its share, less its own + claimant, partner and children. Every other council's variables are zero. +7. Local independence: an adult in a benefit unit who is not its claimant or + partner changes no one else's local deduction through their earnings. """ import numpy as np @@ -426,3 +437,338 @@ def test_every_non_dependant_in_a_benefit_unit_has_a_bedroom(population): # A non-dependant has a bedroom as a responsible qualifying young person # would, so the count is the same either way. assert np.array_equal(rooms, rooms_as_dependants) + + +# 6-7. The English working-age local schemes. Each council's weekly scale for +# 2026-27 (the parameters' first values, which also apply in 2025): Merton +# para 30, Kingston upon Thames para 30A, Newham para 30B, Westminster +# Appendix A and Oxford para 43. +LOCAL_SCHEMES = { + "MERTON": dict( + name="merton", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=True, + each_uc_couple_member=True, + ), + "KINGSTON_UPON_THAMES": dict( + name="kingston_upon_thames", + thresholds=[0, 279, 485, 605], + amounts=[5.64, 11.50, 14.43, 17.31], + benefit_exemptions=True, + each_uc_couple_member=True, + ), + "NEWHAM": dict( + name="newham", + thresholds=[0, 183, 316, 394], + amounts=[7.54, 14.96, 18.84, 22.61], + benefit_exemptions=True, + each_uc_couple_member=False, + ), + "WESTMINSTER": dict( + name="westminster", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=True, + each_uc_couple_member=False, + ), + "OXFORD": dict( + name="oxford", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=False, + each_uc_couple_member=False, + ), +} +EARNINGS = [0, 2_600, 15_600, 30_000] +HOURS = [0, 10, 37.5] + + +@st.composite +def local_family(draw, role): + # Working-age adults; the head's family has the household's oldest + # member, below State Pension age, and every extra member is at most 60. + adult_age = st.integers(61, 65) if role == "head" else st.integers(25, 59) + n = draw(st.integers(1, 2)) + + def adults(strategy): + return draw(st.lists(strategy, min_size=n, max_size=n)) + + return dict( + role=role, + adult_ages=adults(adult_age), + adult_earnings=adults(st.sampled_from(EARNINGS)), + adult_hours=adults(st.sampled_from(HOURS)), + extras=draw(st.lists(extra_member(), min_size=0, max_size=2)), + extra_students=draw(st.lists(st.booleans(), min_size=2, max_size=2)), + child=draw(st.booleans()), + renter_pip=draw(st.booleans()), + payment=draw(st.floats(1_000, 10_000, allow_nan=False)), + would_claim_uc=draw(st.booleans()), + ) + + +@st.composite +def local_household(draw): + head = draw(local_family("head")) + others = [ + draw(local_family(draw(st.sampled_from(ROLES)))) + for _ in range(draw(st.integers(0, 2))) + ] + return dict( + families=[head] + others, + rent=draw(st.floats(3_000, 20_000, allow_nan=False)), + tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), + local_authority=draw(st.sampled_from(sorted(LOCAL_SCHEMES))), + ) + + +local_population = st.lists(local_household(), min_size=1, max_size=5) + + +def build_local(population, zero_extra_earnings=False): + """build(), plus each adult's earnings and hours, each person's education + (an extra member may be a full-time student), each family's Universal + Credit claim and each household's council in England.""" + situation, person_rows, family_rows = build(population) + people = list(situation["people"].values()) + row_iter = iter(zip(people, person_rows)) + for h, house in enumerate(population): + situation["households"][f"h{h}"].update( + country="ENGLAND", local_authority=house["local_authority"] + ) + for f, fam in enumerate(house["families"]): + situation["benunits"][f"h{h}_f{f}"]["would_claim_uc"] = fam[ + "would_claim_uc" + ] + members = ( + [("adult", i) for i in range(len(fam["adult_ages"]))] + + [("extra", i) for i in range(len(fam["extras"]))] + + ([("child", 0)] if fam["child"] else []) + ) + for kind, i in members: + person, row = next(row_iter) + if kind == "adult": + earnings = fam["adult_earnings"][i] + hours = fam["adult_hours"][i] + student = False + elif kind == "extra": + earnings = person["employment_income"] + if zero_extra_earnings: + earnings = 0.0 + hours = person["weekly_hours"] + student = fam["extra_students"][i] + else: + earnings, hours, student = 0.0, 0.0, False + person.update( + employment_income=float(earnings), + weekly_hours=float(hours), + current_education="TERTIARY" if student else "NOT_IN_EDUCATION", + ) + row.update( + earnings=float(earnings), + hours=float(hours), + student=student, + claimant_or_partner=kind == "adult", + local_authority=house["local_authority"], + ) + return situation, person_rows, family_rows + + +def weekly_scale(scheme, weekly_income): + amounts = [ + amount + for threshold, amount in zip(scheme["thresholds"], scheme["amounts"]) + if weekly_income >= threshold + ] + return amounts[-1] + + +def local_oracle(person_rows, family_rows, benefits, claims, ctr_share): + """Each person's deduction under their council's scheme and each family's + total, from the generated structure and the families' benefit awards.""" + n_people, n_families = len(person_rows), len(family_rows) + individual = np.zeros(n_people) + counted = np.zeros(n_people) + for p, row in enumerate(person_rows): + f = row["family"] + family = family_rows[f] + scheme = LOCAL_SCHEMES[row["local_authority"]] + # Reg 9: a member of a family not liable for the rent, or an adult in + # any family's benefit unit who is not its claimant, partner, child or + # young person; aged 18 or over. + eligible = row["age"] >= 18 and ( + family["role"] == "non_dependant" or row["kind"] == "non_dependant" + ) + head = next( + i + for i, other in enumerate(family_rows) + if other["household"] == family["household"] + ) + applicant_exempt = family_rows[head]["renter_pip"] + if not eligible or applicant_exempt or row["student"]: + continue + couple = [ + other + for other in person_rows + if other["family"] == f and other["claimant_or_partner"] + ] + if row["claimant_or_partner"]: + income = sum(member["earnings"] for member in couple) + else: + income = row["earnings"] + weekly = ( + weekly_scale(scheme, income / 52) + if row["hours"] >= 16 + else scheme["amounts"][0] + ) + exempt = False + if scheme["benefit_exemptions"] and row["claimant_or_partner"]: + award = benefits[f] + on_income_related = any( + award[b] > 0 + for b in ( + "income_support", + "jsa_income", + "esa_income", + "pension_credit", + ) + ) + couple_earned = sum(member["earnings"] for member in couple) + exempt = on_income_related or ( + award["universal_credit"] > 0 and couple_earned <= 0 + ) + individual[p] = 0 if exempt else weekly * 52 + # One deduction for a couple, the higher, or each member's in Merton and + # Kingston upon Thames where the couple has Universal Credit; any other + # member separately. + for f, family in enumerate(family_rows): + members = [p for p, row in enumerate(person_rows) if row["family"] == f] + couple = [p for p in members if person_rows[p]["claimant_or_partner"]] + scheme = LOCAL_SCHEMES[person_rows[members[0]]["local_authority"]] + each = scheme["each_uc_couple_member"] and benefits[f]["universal_credit"] > 0 + for p in members: + if p not in couple or each: + counted[p] = individual[p] + if couple and not each: + counted[couple[0]] = max(individual[p] for p in couple) + totals = np.zeros(n_families) + for f, family in enumerate(family_rows): + in_household = [ + p + for p, row in enumerate(person_rows) + if family_rows[row["family"]]["household"] == family["household"] + ] + own = [ + p + for p in in_household + if person_rows[p]["family"] == f + and person_rows[p]["kind"] != "non_dependant" + ] + pool = sum(counted[p] for p in in_household) - sum(counted[p] for p in own) + totals[f] = claims[f] * ctr_share[f] * pool + return individual, totals + + +def run_local(population, zero_extra_earnings=False): + situation, person_rows, family_rows = build_local(population, zero_extra_earnings) + sim = Simulation(situation=situation) + awards = { + b: calc(sim, b) + for b in ( + "income_support", + "jsa_income", + "esa_income", + "pension_credit", + "universal_credit", + ) + } + benefits = [{b: awards[b][f] for b in awards} for f in range(len(family_rows))] + claims = calc(sim, "council_tax_reduction_claimant_benunit") + ctr_share = calc(sim, "council_tax_reduction_joint_liability_share") + return sim, person_rows, family_rows, benefits, claims, ctr_share + + +@PROPERTY_SETTINGS +@given(local_population) +def test_local_scheme_deductions_match_the_oracle(population): + sim, person_rows, family_rows, benefits, claims, ctr_share = run_local(population) + individual, totals = local_oracle( + person_rows, family_rows, benefits, claims, ctr_share + ) + person_council = np.array([row["local_authority"] for row in person_rows]) + family_council = np.array( + [ + person_council[[r["family"] for r in person_rows].index(f)] + for f in range(len(family_rows)) + ] + ) + for council, scheme in LOCAL_SCHEMES.items(): + name = scheme["name"] + # 6. Each council's variables follow its own scheme and are zero + # elsewhere. + assert np.allclose( + calc(sim, f"{name}_council_tax_reduction_individual_non_dep_deduction"), + np.where(person_council == council, individual, 0), + atol=0.01, + ), name + assert np.allclose( + calc(sim, f"{name}_council_tax_reduction_non_dep_deductions"), + np.where(family_council == council, totals, 0), + atol=0.01, + ), name + + +def with_an_earning_adult_beside_its_claimant(population): + """Each household plus a family not liable for the rent whose claimant + works 37.5 hours without earnings beside a 22-year-old earning £30,000, + and an applicant with no exemption, so the property always binds.""" + family = dict( + role="non_dependant", + adult_ages=[40], + adult_earnings=[0], + adult_hours=[37.5], + extras=[ + dict( + kind="non_dependant", + age=22, + earnings=30_000, + hours=37.5, + attendance_allowance=False, + ) + ], + extra_students=[False, False], + child=False, + renter_pip=False, + payment=1_000.0, + would_claim_uc=False, + ) + return [ + dict( + house, + families=[dict(house["families"][0], renter_pip=False)] + + house["families"][1:] + + [family], + ) + for house in population + ] + + +@PROPERTY_SETTINGS +@given(local_population) +def test_local_scheme_in_unit_earnings_affect_only_their_own_deduction(population): + population = with_an_earning_adult_beside_its_claimant(population) + sim, person_rows, *_ = run_local(population) + without, *_ = run_local(population, zero_extra_earnings=True) + # 7. Zeroing the earnings of members who are not a claimant or partner + # leaves every claimant's and partner's deduction unchanged. + claimant_or_partner = np.array([row["claimant_or_partner"] for row in person_rows]) + for scheme in LOCAL_SCHEMES.values(): + variable = ( + f"{scheme['name']}_council_tax_reduction_individual_non_dep_deduction" + ) + assert np.allclose( + calc(sim, variable)[claimant_or_partner], + calc(without, variable)[claimant_or_partner], + atol=0.01, + ), variable From 3d4f12b24f6ed0d421ed940551e66d424d15db51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:06:22 -0400 Subject: [PATCH 004/141] Cap the Pension Credit earnings disregard at net earnings (review of #2019) Reg 17(9) disregards the Sch VI sums in calculating the claimant's earnings, which are net of income tax and NI (reg 17(10)) and of half of pension contributions (reg 17A(4A)). Capping at gross earnings let a taxpayer's first pounds of earnings raise Pension Credit. Income tax on earnings is the lesser of the person's income tax and the basic rate on their earnings. Also: the carer condition reads the claimant's or partner's is_carer_for_benefits (Sch I para 4), not the carer addition, which counts dependants; is_claimant_or_partner after #1896; property tests compute net earnings independently, include dependants' DLA and caring, and add that earning more never raises Pension Credit; parameter description and test comment fixes. Co-Authored-By: Claude Opus 5.5 --- .../higher/disability_benefits.yaml | 14 ++- .../claimant_partner_income_and_carers.yaml | 8 +- .../pension_credit_earnings_disregard.yaml | 65 +++++++++++ .../pension_credit/pension_credit_income.yaml | 6 +- ...on_credit_earnings_disregard_properties.py | 109 +++++++++++++----- .../pension_credit_earnings_disregard.py | 42 +++++-- 6 files changed, 196 insertions(+), 48 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml index 30a23dc061..e2ea6f3328 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -5,11 +5,13 @@ description: > award, is in `disability_benefit_unit_benefits`. `incapacity_benefit` stands for long-term incapacity benefit (para. 4(1)(a)(i)); the model does not separate the short-term rates. Not modelled: - the war pensions mobility supplement (v), the working tax credit disability - elements (vi), pension age disability payment (iiia, from 21 October 2024), - Scottish adult disability living allowance (iva, from 21 March 2025) and - adult disability payment (viiia, from 21 March 2022), which have no separate - variables. + the war pensions mobility supplement (v), which has no variable; pension age + disability payment (iiia, from 21 October 2024), Scottish adult disability + living allowance (iva, from 21 March 2025) and adult disability payment + (viiia, from 21 March 2022), which have no separate variables; and the + working tax credit disability elements (vi), left out because in the model + they follow from DLA or PIP, which are listed, and working tax credit ended + in April 2025. metadata: label: Pension Credit higher earnings disregard disability benefits unit: list @@ -20,7 +22,7 @@ metadata: href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 27(8)(b) (PIP, from 8 April 2013) href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/27 - - title: The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013) + - title: "The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions: Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013)" href: https://www.legislation.gov.uk/uksi/2013/591/schedule/paragraph/23 values: 2003-10-06: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml index dbf4bb5dbf..991253993f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -160,9 +160,13 @@ is_claimant_or_partner: [true, true] # SPCA 2002 s.5: gross = 5,000 + 1,000 + 100 + 2,000 + 500 + 200 # + 104 = 8,904; deductions = 100 + 50 + 25 + 50% * 300 = 325. - pension_credit_income: 8_579 + # Sch VI para 3 (both carers): 20 pounds a week, capped at net earnings + # (claimant 1,000 - 100 tax - 100 half contributions = 800; partner + # 500 - 25 NI - 50 tax - 50 = 375; 1,175), so 1,040 is disregarded: + # 8,904 - 325 - 1,040. + pension_credit_income: 7_539 # Reg.9: remove the claimant's 100 JSA and partner's 200 ESA. - savings_credit_income: 8_279 + savings_credit_income: 7_239 # Sch.I para.4 and the 2026 carer addition: 2 * 48.15 * 52 = 5,007.60. carer_minimum_guarantee_addition: 5_007.60 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 2f09ea9468..1a042c7dba 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -248,3 +248,68 @@ # minimum guarantee 218.15 a week in 2024-25 (SI 2024/242) pension_credit_income: 9_000 + 2_000 - 5 * 52 guarantee_credit: 218.15 * 52 - (9_000 + 2_000 - 5 * 52) + +- name: A taxpayer's disregard is capped at net earnings, so earnings below the disregard leave Pension Credit income unchanged (reg 17(9), 17(10)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Income tax (16,040 - 12,570) x 20% = 694. Tax on the earnings is the + # lesser of 694 and 20% x 1,040 = 208, so net earnings are 832; the 20 + # pound (attendance allowance) disregard of 1,040 is capped at 832. + pension_credit_earnings_disregard: 832 + # 16,040 - 694 - 832, the same as with no earnings (next case). + pension_credit_income: 14_514 + +- name: The same claimant with no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # 15,000 - (15,000 - 12,570) x 20% + pension_credit_earnings_disregard: 0 + pension_credit_income: 14_514 + +- name: A dependant's caring does not qualify a couple for the higher disregard (Sch I para 4 is the claimant's or partner's). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2_000 + partner: + age: 70 + child: + age: 17 + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index 3c1b7febc4..b33aeac542 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -20,9 +20,9 @@ savings: 11_000 output: pension_credit_deemed_income: 104 - # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI para 5 - # disregard: 5 pounds a week for a single claimant, capped at the 129 of - # earnings. + # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI disregard: + # the 20 pound para 4(1)(a)(vii) amount (contributory ESA), capped at the + # 129 of net earnings (no tax or NI on them). pension_credit_earnings_disregard: 129 pension_credit_income: 214 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index 2aaa136839..5c1e475c86 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,19 +1,24 @@ """Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). -Invariants, over generated pension-age benefit units: - -1. Statute: the disregard equals min(earnings, 52 x w), where w is 20 if the - unit has a lone parent, a carer satisfying Sch I para 4, or a claimant or - partner on a listed disability benefit or certified blind, and otherwise 5 - for a single claimant and 10 for a couple. The expected value is computed - here from the inputs, not read from the model. -2. Bounds: 0 <= disregard <= min(earnings, 20 x 52). +Invariants, over generated benefit units (England, so the basic rate is 20%): + +1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are + the claimant's and partner's gross earnings less earnings NI, half of + pension contributions and income tax on the earnings (the lesser of their + income tax and 20% of their earnings), recomputed here from the inputs and + the model's tax and NI. w is 20 if the unit is a lone-parent family, or a + claimant or partner is a carer (an input here), receives a listed + disability benefit or is blind; otherwise 5 single, 10 couple. A dependent + child's DLA or caring never qualifies. +2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). 3. Differential: Pension Credit income is the pre-change income (sources less - tax, NI and half of pension contributions, floored at 0, recomputed from - the model's own components) less the disregard, floored at 0. -4. Monotonicity: the guarantee credit never falls when the disregard applies, - i.e. it is at least the guarantee credit without it. -5. The dated benefit lists follow the amending instruments: ESA from 27 + tax, NI and half of pension contributions, floored at 0) less the + disregard, floored at 0. +4. Monotonicity: the guarantee credit is never below its value without the + disregard. +5. Earning more never raises Pension Credit entitlement (reg 17(9) applies the + disregard to net earnings). +6. The dated benefit lists follow the amending instruments: ESA from 27 October 2008, PIP and AFIP from 8 April 2013. """ @@ -43,16 +48,16 @@ @st.composite def units(draw): couple = draw(st.booleans()) - child = draw(st.booleans()) and not couple + child = draw(st.booleans()) people = {} names = ["claimant"] + (["partner"] if couple else []) for n in names: person = { "age": draw(st.sampled_from([67, 72, 85])), - "employment_income": draw(st.sampled_from([0, 50, 400, 3_000])), + "employment_income": draw(st.sampled_from([0, 50, 400, 1_040, 3_000])), "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), "state_pension": draw(st.integers(min_value=0, max_value=12_000)), - "private_pension_income": draw(st.sampled_from([0, 2_000])), + "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), } @@ -61,7 +66,12 @@ def units(draw): person[benefit] = 2_000 people[n] = person if child: - people["child"] = {"age": 10, "dla_sc": draw(st.sampled_from([0, 2_000]))} + # A dependent child's DLA or caring never qualifies the unit. + people["child"] = { + "age": 10, + "dla_sc": draw(st.sampled_from([0, 2_000])), + "is_carer_for_benefits": draw(st.booleans()), + } return people @@ -80,24 +90,51 @@ def simulate(people): ) +def unit_value(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + +def person_values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR, map_to="person"), dtype=float) + + @SETTINGS @given(units()) def test_statute_bounds_differential_and_monotonicity(people): sim = simulate(people) - calc = lambda v: float(sim.calculate(v, YEAR)[0]) - adults = [p for n, p in people.items() if n != "child"] - earnings = sum(p["employment_income"] + p["self_employment_income"] for p in adults) - couple = len(adults) == 2 + names = list(people) + adults = [n != "child" for n in names] + gross = np.array( + [ + people[n]["employment_income"] + people[n].get("self_employment_income", 0) + if n != "child" + else 0 + for n in names + ], + dtype=float, + ) + ni = sum( + person_values(sim, v) + for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + tax_on_earnings = np.minimum(person_values(sim, "income_tax"), 0.2 * gross) + contributions = 0.5 * person_values(sim, "pension_contributions") + net = np.maximum(0, gross - ni - tax_on_earnings - contributions) + net_earnings = float(net[adults].sum()) + + adult_people = [people[n] for n in names if n != "child"] + couple = len(adult_people) == 2 disabled = any( - p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adults + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) + for p in adult_people ) - carer = calc("carer_minimum_guarantee_addition") > 0 - lone_parent = "child" in people + carer = any(p.get("is_carer_for_benefits") for p in adult_people) + lone_parent = "child" in people and not couple weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) - expected = min(earnings, weekly * WEEKS_IN_YEAR) - disregard = calc("pension_credit_earnings_disregard") + expected = min(net_earnings, weekly * WEEKS_IN_YEAR) + disregard = unit_value(sim, "pension_credit_earnings_disregard") assert np.isclose(disregard, expected, atol=0.01) - assert 0 <= disregard <= min(earnings, 20 * WEEKS_IN_YEAR) + 0.01 + assert 0 <= disregard <= min(gross.sum(), 20 * WEEKS_IN_YEAR) + 0.01 sources = sim.tax_benefit_system.parameters( f"{YEAR}-06-01" @@ -110,10 +147,22 @@ def test_statute_bounds_differential_and_monotonicity(people): ) before = max(0.0, total - deductions) assert np.isclose( - calc("pension_credit_income"), max(0.0, before - disregard), atol=0.01 + unit_value(sim, "pension_credit_income"), + max(0.0, before - disregard), + atol=0.01, ) - gc_without = max(0.0, calc("minimum_guarantee") - before) - assert calc("guarantee_credit") >= gc_without - 0.01 + gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) + assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + + +@SETTINGS +@given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) +def test_earning_more_never_raises_pension_credit(people, extra): + more = {name: dict(person) for name, person in people.items()} + more["claimant"]["employment_income"] += extra + before = unit_value(simulate(people), "pension_credit_entitlement") + after = unit_value(simulate(more), "pension_credit_entitlement") + assert after <= before + 0.01 def test_benefit_lists_follow_amending_instruments(): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 5b93defa35..549bd43a6a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -11,8 +11,14 @@ class pension_credit_earnings_disregard(Variable): "receives a listed disability benefit or is certified blind " "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " "pounds for a couple (para. 5). 20 pounds is the most disregarded " - "however many conditions are met (para. 4A), and the disregard never " - "exceeds the earnings. Not modelled: the employment-specific 20 pound " + "however many conditions are met (para. 4A). Reg. 17(9) disregards the " + "sums 'in calculating the claimant's earnings', which are net of income " + "tax and National Insurance (reg. 17(10)) and of half of pension " + "contributions (reg. 17A(4A)), so the disregard never exceeds the " + "claimant's and partner's net earnings. Income tax on earnings is taken " + "as the lesser of the person's income tax and the basic rate on their " + "earnings, the tax deducted where the personal allowance goes against " + "pension income first. Not modelled: the employment-specific 20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " "protections of para. 4(2) to (4)." @@ -24,12 +30,15 @@ class pension_credit_earnings_disregard(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", ) def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.pension_credit.earnings_disregard + pc = parameters(period).gov.dwp.pension_credit + p = pc.earnings_disregard person = benunit.members - claimant_or_partner = person("is_uc_claimant", period) + claimant_or_partner = person("is_claimant_or_partner", period) + on_disability_benefit = ( add(person, period, p.higher.disability_benefits) > 0 ) | person("is_blind", period) @@ -43,7 +52,10 @@ def formula(benunit, period, parameters): benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit ) - carer = benunit("carer_minimum_guarantee_addition", period) > 0 + # Sch. I para. 4: a claimant or partner entitled to carer's allowance. + carer = benunit.any( + claimant_or_partner & person("is_carer_for_benefits", period) + ) lone_parent = benunit("is_lone_parent", period) relation_type = benunit("relation_type", period) weekly = where( @@ -51,5 +63,21 @@ def formula(benunit, period, parameters): p.higher.amount, p.standard[relation_type], ) - earnings = max_(0, benunit("pension_credit_earnings", period)) - return min_(weekly * WEEKS_IN_YEAR, earnings) + + # Net earnings of the claimant and partner (regs. 17(10), 17A(4A)). + gross = max_(0, add(person, period, pc.guarantee_credit.earnings_sources)) + national_insurance = add( + person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + basic_rate = parameters(period).gov.hmrc.income_tax.rates.uk.rates[0] + income_tax_on_earnings = min_(person("income_tax", period), basic_rate * gross) + pension_contributions = ( + person("pension_contributions", period) + * pc.income.pension_contributions_deduction + ) + net = max_( + 0, + gross - national_insurance - income_tax_on_earnings - pension_contributions, + ) + net_earnings = benunit.sum(net * claimant_or_partner) + return min_(weekly * WEEKS_IN_YEAR, net_earnings) From 663a5b7333c0a27492831ba7c7f33a463289dcf9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:34:18 -0400 Subject: [PATCH 005/141] Apply the para 9(2)(d)-(e) exclusions to the person, whatever their family Review r1 of #2077: an in-unit adult who personally pays the householder as a boarder or lodger, or is personally liable for the rent, was still a non-dependant, and a household head whom the inference left outside his benefit unit's couple was charged as his own non-dependant. The household head (the person liable for the council tax), anyone liable for the household's rent and anyone paying rent as a boarder or lodger is now nobody's non-dependant, in the national and local schemes alike. Property 6's oracle now derives who claims and each claim's share from the generated structure and checks the model's against them. Co-Authored-By: Claude Opus 5.5 --- .../local_scheme_non_dependants.yaml | 150 ++++++++++++++++++ ...t_benefit_unit_non_dependant_properties.py | 35 +++- ...n_individual_non_dep_deduction_eligible.py | 24 ++- 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml index 492c43583c..72d41a926c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -708,3 +708,153 @@ # only. oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] council_tax_reduction: 838 + +# Para 9(2)(d)-(e) exclude the person, whatever family they are in: someone who +# pays the householder as a boarder or lodger, someone jointly liable for the +# council tax, and the liable person (the household head) are nobody's +# non-dependants. + +- name: Merton makes no deduction for an adult son who himself pays as a lodger + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 40 + is_claimant_or_partner: true + rent_paid_as_lodger: 3_000 + son: + age: 25 + is_claimant_or_partner: false + rent_paid_as_lodger: 3_000 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + applicant_family: + members: [applicant] + would_claim_uc: false + claims_all_entitled_benefits: true + lodging_family: + members: [lodger, son] + would_claim_uc: false + households: + household: + members: [applicant, lodger, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + council_tax_reduction: 1_800 + +- name: Merton charges a lodger's adult son who does not pay the householder himself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 40 + is_claimant_or_partner: true + rent_paid_as_lodger: 3_000 + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + applicant_family: + members: [applicant] + would_claim_uc: false + claims_all_entitled_benefits: true + lodging_family: + members: [lodger, son] + would_claim_uc: false + households: + household: + members: [applicant, lodger, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 9(2)(e) excludes only the person liable to pay the applicant. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 + +- name: Merton makes no deduction for an adult son who is himself a joint tenant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + is_liable_for_household_rent: true + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 9(2)(d): jointly and severally liable with the applicant. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + merton_council_tax_reduction_non_dep_deductions: 0 + +- name: Merton never charges the applicant as their own non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + young_mother: + age: 17 + is_parent: true + young_father: + age: 17 + is_parent: true + baby: + age: 0 + benunits: + benunit: + members: [applicant, young_mother, young_father, baby] + would_claim_uc: false + households: + household: + members: [applicant, young_mother, young_father, baby] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # The inference makes the young parents the benefit unit's couple, so + # the household head is not its claimant or partner; he is still the + # person liable for the council tax, and everyone else is under 18. + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, false] + merton_council_tax_reduction_non_dep_deductions: 0 diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index f39fd40735..de7ddb1db0 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -684,15 +684,42 @@ def run_local(population, zero_extra_earnings=False): ) } benefits = [{b: awards[b][f] for b in awards} for f in range(len(family_rows))] - claims = calc(sim, "council_tax_reduction_claimant_benunit") - ctr_share = calc(sim, "council_tax_reduction_joint_liability_share") - return sim, person_rows, family_rows, benefits, claims, ctr_share + return sim, person_rows, family_rows, benefits + + +def legal_claims_and_shares(family_rows): + """Who claims and each claim's share, from the generated structure alone. + Where a family shares the rent, every family liable for it (the head's + and each sharer's) is jointly and severally liable for the council tax and + claims, each person liable bearing an equal share of the tax and of each + shared non-dependant's deduction (para 29(3)-(4), 30(5)); otherwise the + head's family claims alone, in full.""" + n = len(family_rows) + claims, shares = np.zeros(n), np.ones(n) + for h in {family["household"] for family in family_rows}: + families = [i for i, f in enumerate(family_rows) if f["household"] == h] + liable = [i for i in families if family_rows[i]["role"] in ("head", "sharer")] + shared = any(family_rows[i]["role"] == "sharer" for i in families) + liable_people = sum(family_rows[i]["n_liable"] for i in liable) + for i in families: + if shared: + claims[i] = i in liable + shares[i] = 1 / liable_people if i in liable else 1 + else: + claims[i] = family_rows[i]["role"] == "head" + return claims, shares @PROPERTY_SETTINGS @given(local_population) def test_local_scheme_deductions_match_the_oracle(population): - sim, person_rows, family_rows, benefits, claims, ctr_share = run_local(population) + sim, person_rows, family_rows, benefits = run_local(population) + claims, ctr_share = legal_claims_and_shares(family_rows) + # The claims and shares the totals use are the scheme's, not the model's. + assert np.array_equal(calc(sim, "council_tax_reduction_claimant_benunit"), claims) + assert np.allclose( + calc(sim, "council_tax_reduction_joint_liability_share"), ctr_share + ) individual, totals = local_oracle( person_rows, family_rows, benefits, claims, ctr_share ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 2c39ee3daa..4043364281 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -16,7 +16,11 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): "including a boarder's or lodger's adult son. Someone jointly and " "severally liable for the council tax with the claimant (a joint " "tenant or other sharer of the rent) and a boarder or lodger who pays " - "the claimant are not non-dependants." + "the claimant are not non-dependants. Those exclusions belong to the " + "person, whatever family they are in: the household head (the person " + "liable for the council tax), anyone liable for the household's rent " + "and anyone who pays rent as a boarder or lodger is nobody's " + "non-dependant." ) definition_period = YEAR reference = ( @@ -34,4 +38,20 @@ def formula(person, period, parameters): # family. Reg 9(2)(e) excludes only the person liable to pay the # applicant, so a boarder's or lodger's adult son is a non-dependant. in_unit = person("is_benefit_unit_non_dependant_for_legacy_benefits", period) - return (person("age", period) >= 18) & (other_family | in_unit) + # Reg 9(2)(d)-(e) exclude the person, not their family: the liable + # person (LGFA 1992 s.6) and any joint occupier are jointly and + # severally liable with an applicant, and a boarder or lodger pays on + # a commercial basis. So an applicant is never their own + # non-dependant, even where the benefit unit's claimant and partner + # are other members of it. + personally_excluded = ( + person("is_household_head", period) + | person("is_liable_for_household_rent", period) + | (person("rent_paid_as_lodger", period) > 0) + | (person("rent_paid_as_boarder", period) > 0) + ) + return ( + (person("age", period) >= 18) + & (other_family | in_unit) + & ~personally_excluded + ) From 816a4e1ea156f138b054364ce3822e07a3d27a9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:34:20 -0400 Subject: [PATCH 006/141] Tax earnings as the top slice on the person's schedule; carer entitlement (review of #2019) - Income tax on earnings is now the tax on them as the top slice of taxable non-savings income on the person's own Scottish or rest-of-UK schedule, capped at their income tax. The 20% cap let earnings crossing Scottish bands raise Pension Credit (reviewer's couple: +2.89). - The para 3 carer condition reads is_entitled_to_carer_benefit, the carer addition's own Sch I para 4 test; caring hours alone no longer qualify (reviewer's case overpaid 780 a year). - Tests: Scottish couple pair (income flat with and without earnings), hours-only vs entitled carer cases; the property test adds Scottish households, carer entitlement, care hours and pension contributions, and measures tax on earnings independently by rerunning without them. Co-Authored-By: Claude Opus 5.5 --- .../pension_credit_earnings_disregard.yaml | 96 ++++++++++++++++++- ...on_credit_earnings_disregard_properties.py | 62 ++++++++---- .../pension_credit_earnings_disregard.py | 30 ++++-- 3 files changed, 160 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 1a042c7dba..709c69578e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -78,7 +78,7 @@ claimant: age: 70 employment_income: 2000 - is_carer_for_benefits: true + is_entitled_to_carer_benefit: true benunits: benunit: members: [claimant] @@ -304,7 +304,7 @@ age: 70 child: age: 17 - is_carer_for_benefits: true + is_entitled_to_carer_benefit: true benunits: benunit: members: [claimant, partner, child] @@ -313,3 +313,95 @@ members: [claimant, partner, child] output: pension_credit_earnings_disregard: 10 * 52 + +- name: A Scottish taxpayer's tax on earnings is their top slice on the Scottish schedule, so Pension Credit income does not fall with earnings. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + employment_income: 1_040 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + # The 1,040 of earnings add 210.89 of income tax (they cross into the + # Scottish intermediate band), so net earnings are 829.11 and the 20 pound + # disregard of 1,040 is capped there; income is as with no earnings (next). + income_tax: [3_016.62, 0] + pension_credit_earnings_disregard: 829.11 + pension_credit_income: 25_194.27 + +- name: The same Scottish couple with no earnings has the same Pension Credit income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + income_tax: [2_805.73, 0] + pension_credit_earnings_disregard: 0 + pension_credit_income: 25_194.27 + +- name: Caring hours without entitlement to carer's allowance do not qualify for the 20 pound disregard (Sch VI para 3 needs Sch I para 4). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Entitlement to carer's allowance qualifies for the 20 pound disregard. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index 5c1e475c86..c60d3de0f2 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,13 +1,15 @@ """Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). -Invariants, over generated benefit units (England, so the basic rate is 20%): +Invariants, over generated benefit units in England and Scotland: 1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are the claimant's and partner's gross earnings less earnings NI, half of - pension contributions and income tax on the earnings (the lesser of their - income tax and 20% of their earnings), recomputed here from the inputs and - the model's tax and NI. w is 20 if the unit is a lone-parent family, or a - claimant or partner is a carer (an input here), receives a listed + pension contributions and income tax on the earnings. The tax on a + person's earnings is measured independently, as the fall in their income + tax when the simulation is rerun without their earnings (the earnings as + the top slice, on their own Scottish or rest-of-UK schedule). w is 20 if + the unit is a lone-parent family, or a claimant or partner is entitled to + carer's allowance (an input here), receives a listed disability benefit or is blind; otherwise 5 single, 10 couple. A dependent child's DLA or caring never qualifies. 2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). @@ -47,6 +49,7 @@ @st.composite def units(draw): + country = draw(st.sampled_from(["ENGLAND", "SCOTLAND"])) couple = draw(st.booleans()) child = draw(st.booleans()) people = {} @@ -59,7 +62,11 @@ def units(draw): "state_pension": draw(st.integers(min_value=0, max_value=12_000)), "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), - "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), + "is_entitled_to_carer_benefit": draw( + st.sampled_from([False, False, False, True]) + ), + "care_hours": draw(st.sampled_from([0, 0, 40])), + "personal_pension_contributions": draw(st.sampled_from([0, 0, 300])), } benefit = draw(st.sampled_from([None, None] + DISABILITY)) if benefit: @@ -70,12 +77,13 @@ def units(draw): people["child"] = { "age": 10, "dla_sc": draw(st.sampled_from([0, 2_000])), - "is_carer_for_benefits": draw(st.booleans()), + "is_entitled_to_carer_benefit": draw(st.booleans()), } - return people + return {"people": people, "country": country} -def simulate(people): +def simulate(case): + people = case["people"] members = list(people) dated = { name: {k: {str(YEAR): v} for k, v in person.items()} @@ -85,11 +93,20 @@ def simulate(people): situation={ "people": dated, "benunits": {"b": {"members": members}}, - "households": {"h": {"members": members}}, + "households": { + "h": {"members": members, "country": {str(YEAR): case["country"]}} + }, } ) +def without_earnings(case, name): + people = {n: dict(p) for n, p in case["people"].items()} + people[name]["employment_income"] = 0 + people[name]["self_employment_income"] = 0 + return {"people": people, "country": case["country"]} + + def unit_value(sim, variable): return float(sim.calculate(variable, YEAR)[0]) @@ -100,8 +117,9 @@ def person_values(sim, variable): @SETTINGS @given(units()) -def test_statute_bounds_differential_and_monotonicity(people): - sim = simulate(people) +def test_statute_bounds_differential_and_monotonicity(case): + people = case["people"] + sim = simulate(case) names = list(people) adults = [n != "child" for n in names] gross = np.array( @@ -117,7 +135,12 @@ def test_statute_bounds_differential_and_monotonicity(people): person_values(sim, v) for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] ) - tax_on_earnings = np.minimum(person_values(sim, "income_tax"), 0.2 * gross) + income_tax = person_values(sim, "income_tax") + tax_on_earnings = np.zeros(len(names)) + for i, name in enumerate(names): + if gross[i] > 0: + rerun = person_values(simulate(without_earnings(case, name)), "income_tax") + tax_on_earnings[i] = max(0.0, income_tax[i] - rerun[i]) contributions = 0.5 * person_values(sim, "pension_contributions") net = np.maximum(0, gross - ni - tax_on_earnings - contributions) net_earnings = float(net[adults].sum()) @@ -128,7 +151,7 @@ def test_statute_bounds_differential_and_monotonicity(people): p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adult_people ) - carer = any(p.get("is_carer_for_benefits") for p in adult_people) + carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) lone_parent = "child" in people and not couple weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) expected = min(net_earnings, weekly * WEEKS_IN_YEAR) @@ -157,11 +180,14 @@ def test_statute_bounds_differential_and_monotonicity(people): @SETTINGS @given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) -def test_earning_more_never_raises_pension_credit(people, extra): - more = {name: dict(person) for name, person in people.items()} +def test_earning_more_never_raises_pension_credit(case, extra): + more = {name: dict(person) for name, person in case["people"].items()} more["claimant"]["employment_income"] += extra - before = unit_value(simulate(people), "pension_credit_entitlement") - after = unit_value(simulate(more), "pension_credit_entitlement") + before = unit_value(simulate(case), "pension_credit_entitlement") + after = unit_value( + simulate({"people": more, "country": case["country"]}), + "pension_credit_entitlement", + ) assert after <= before + 0.01 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 549bd43a6a..2e39753ac5 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -15,10 +15,13 @@ class pension_credit_earnings_disregard(Variable): "sums 'in calculating the claimant's earnings', which are net of income " "tax and National Insurance (reg. 17(10)) and of half of pension " "contributions (reg. 17A(4A)), so the disregard never exceeds the " - "claimant's and partner's net earnings. Income tax on earnings is taken " - "as the lesser of the person's income tax and the basic rate on their " - "earnings, the tax deducted where the personal allowance goes against " - "pension income first. Not modelled: the employment-specific 20 pound " + "claimant's and partner's net earnings. Income tax on earnings is the " + "tax on them as the top slice of the person's taxable non-savings " + "income, on the person's own rate schedule (Scottish or rest of UK), " + "capped at their income tax: the tax deducted from the earnings where " + "the personal allowance goes against pension income first (DMG " + "86038-86039), so earning more never raises Pension Credit. Not " + "modelled: the employment-specific 20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " "protections of para. 4(2) to (4)." @@ -52,9 +55,10 @@ def formula(benunit, period, parameters): benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit ) - # Sch. I para. 4: a claimant or partner entitled to carer's allowance. + # Sch. I para. 4: a claimant or partner entitled to carer's allowance + # (the carer addition's own test), not caring hours alone. carer = benunit.any( - claimant_or_partner & person("is_carer_for_benefits", period) + claimant_or_partner & person("is_entitled_to_carer_benefit", period) ) lone_parent = benunit("is_lone_parent", period) relation_type = benunit("relation_type", period) @@ -69,8 +73,18 @@ def formula(benunit, period, parameters): national_insurance = add( person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] ) - basic_rate = parameters(period).gov.hmrc.income_tax.rates.uk.rates[0] - income_tax_on_earnings = min_(person("income_tax", period), basic_rate * gross) + # Tax on the earnings as the top slice of taxable non-savings income. + rates = parameters(period).gov.hmrc.income_tax.rates + taxable = person("earned_taxable_income", period) + below_earnings = max_(0, taxable - min_(gross, taxable)) + scottish = person("pays_scottish_income_tax", period) + top_slice_tax = where( + scottish, + rates.scotland.rates.calc(taxable) + - rates.scotland.rates.calc(below_earnings), + rates.uk.calc(taxable) - rates.uk.calc(below_earnings), + ) + income_tax_on_earnings = min_(person("income_tax", period), top_slice_tax) pension_contributions = ( person("pension_contributions", period) * pc.income.pension_contributions_deduction From 7ef035261313e1bc26112e4f76ade77945d49dce Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 15 Sep 2026 13:05:30 +0100 Subject: [PATCH 007/141] Compute gross council tax from LA Band D amounts and band ratios Add council_tax_band_d_amount (LA Band D amount, from a new LA-keyed parameter following the domestic_rates/rates.yaml precedent) and simulated_council_tax (Band D amount x statutory band ratio). Band ratios are added per country: England and Wales use the section 5 proportions 6:7:8:9:11:13:15:18 relative to Band D = 9, Wales adding band I at 21/9 from April 2005; Scotland uses the raised E-H multipliers (473/360, 585/360, 705/360, 882/360) from April 2017. The existing council_tax input variable is left untouched so the two can be compared. council_tax_band_d_amount also fills a dangling reference from policyengine-uk-data's la_council_tax target source. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/council-tax-computed-liability.md | 1 + .../council_tax/band_d/amounts.yaml | 702 ++++++++++++++++++ .../council_tax/band_ratios/england.yaml | 26 + .../council_tax/band_ratios/scotland.yaml | 28 + .../council_tax/band_ratios/wales.yaml | 30 + .../council_tax/simulated_council_tax.yaml | 239 ++++++ .../council_tax/council_tax_band_d_amount.py | 25 + .../council_tax/simulated_council_tax.py | 34 + 8 files changed, 1085 insertions(+) create mode 100644 changelog.d/council-tax-computed-liability.md create mode 100644 policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py diff --git a/changelog.d/council-tax-computed-liability.md b/changelog.d/council-tax-computed-liability.md new file mode 100644 index 0000000000..2747986d00 --- /dev/null +++ b/changelog.d/council-tax-computed-liability.md @@ -0,0 +1 @@ +Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml new file mode 100644 index 0000000000..7d69ffc825 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml @@ -0,0 +1,702 @@ +description: Band D council tax amount set by each local authority, before any discounts, exemptions or Council Tax Reduction. +metadata: + economy: false + propagate_metadata_to_children: true + unit: currency-GBP + period: year + label: Local authority Band D council tax amount + reference: + - title: Council tax levels set by local authorities in England 2026 to 2027 + href: https://www.gov.uk/government/collections/council-tax-statistics + - title: Council tax levels April 2026 to March 2027 (Wales) + href: https://www.gov.wales/council-tax-levels-april-2026-march-2027-html + - title: Council Tax datasets (Scotland) + href: https://www.gov.scot/publications/council-tax-datasets/ +ABERDEEN_CITY: + 2025-01-01: 1636.27 +ABERDEENSHIRE: + 2025-01-01: 1532.76 +ADUR: + 2026-01-01: 2548.28 +AMBER_VALLEY: + 2026-01-01: 2407.69 +ANGUS: + 2025-01-01: 1461.52 +ARGYLL_AND_BUTE: + 2025-01-01: 1625.64 +ARUN: + 2026-01-01: 2487.24 +ASHFIELD: + 2026-01-01: 2608.76 +ASHFORD: + 2026-01-01: 2410.16 +BABERGH: + 2026-01-01: 2341.55 +BARKING_AND_DAGENHAM: + 2026-01-01: 2198.51 +BARNET: + 2026-01-01: 2132.60 +BARNSLEY: + 2026-01-01: 2325.96 +BASILDON: + 2026-01-01: 2327.24 +BASINGSTOKE_AND_DEANE: + 2026-01-01: 2255.21 +BASSETLAW: + 2026-01-01: 2644.95 +BATH_AND_NORTH_EAST_SOMERSET: + 2026-01-01: 2383.42 +BEDFORD: + 2026-01-01: 2471.74 +BEXLEY: + 2026-01-01: 2366.36 +BIRMINGHAM: + 2026-01-01: 2362.90 +BLABY: + 2026-01-01: 2490.22 +BLACKBURN_WITH_DARWEN: + 2026-01-01: 2455.27 +BLACKPOOL: + 2026-01-01: 2513.22 +BLAENAU_GWENT: + 2026-01-01: 2531.26 +BOLSOVER: + 2026-01-01: 2559.41 +BOLTON: + 2026-01-01: 2399.74 +BOSTON: + 2026-01-01: 2308.73 +BOURNEMOUTH_CHRISTCHURCH_AND_POOLE: + 2026-01-01: 2435.58 +BRACKNELL_FOREST: + 2026-01-01: 2264.80 +BRADFORD: + 2026-01-01: 2360.73 +BRAINTREE: + 2026-01-01: 2280.34 +BRECKLAND: + 2026-01-01: 2443.67 +BRENT: + 2026-01-01: 2235.27 +BRENTWOOD: + 2026-01-01: 2258.14 +BRIDGEND: + 2026-01-01: 2477.99 +BRIGHTON_AND_HOVE: + 2026-01-01: 2580.54 +BRISTOL_CITY_OF: + 2026-01-01: 2713.68 +BROADLAND: + 2026-01-01: 2437.78 +BROMLEY: + 2026-01-01: 2140.04 +BROMSGROVE: + 2026-01-01: 2478.65 +BROXBOURNE: + 2026-01-01: 2306.43 +BROXTOWE: + 2026-01-01: 2618.21 +BUCKINGHAMSHIRE: + 2026-01-01: 2526.58 +BURNLEY: + 2026-01-01: 2549.42 +BURY: + 2026-01-01: 2555.15 +CAERPHILLY: + 2026-01-01: 2082.88 +CALDERDALE: + 2026-01-01: 2420.15 +CAMBRIDGE: + 2026-01-01: 2467.02 +CAMDEN: + 2026-01-01: 2207.55 +CANNOCK_CHASE: + 2026-01-01: 2375.83 +CANTERBURY: + 2026-01-01: 2419.33 +CARDIFF: + 2026-01-01: 2013.18 +CARMARTHENSHIRE: + 2026-01-01: 2351.20 +CASTLE_POINT: + 2026-01-01: 2318.63 +CENTRAL_BEDFORDSHIRE: + 2026-01-01: 2516.16 +CEREDIGION: + 2026-01-01: 2419.45 +CHARNWOOD: + 2026-01-01: 2405.64 +CHELMSFORD: + 2026-01-01: 2300.18 +CHELTENHAM: + 2026-01-01: 2369.52 +CHERWELL: + 2026-01-01: 2582.96 +CHESHIRE_EAST: + 2026-01-01: 2454.87 +CHESHIRE_WEST_AND_CHESTER: + 2026-01-01: 2517.33 +CHESTERFIELD: + 2026-01-01: 2339.49 +CHICHESTER: + 2026-01-01: 2469.68 +CHORLEY: + 2026-01-01: 2430.23 +CITY_OF_EDINBURGH: + 2025-01-01: 1563.51 +CITY_OF_LONDON: + 2026-01-01: 1329.56 +CLACKMANNANSHIRE: + 2025-01-01: 1594.38 +COLCHESTER: + 2026-01-01: 2282.85 +CONWY: + 2026-01-01: 2472.82 +CORNWALL: + 2026-01-01: 2590.93 +COTSWOLD: + 2026-01-01: 2391.65 +COUNTY_DURHAM: + 2026-01-01: 2622.15 +COVENTRY: + 2026-01-01: 2516.72 +CRAWLEY: + 2026-01-01: 2418.46 +CROYDON: + 2026-01-01: 2599.91 +DACORUM: + 2026-01-01: 2408.51 +DARLINGTON: + 2026-01-01: 2493.83 +DARTFORD: + 2026-01-01: 2375.15 +DENBIGHSHIRE: + 2026-01-01: 2339.03 +DERBY: + 2026-01-01: 2306.00 +DERBYSHIRE_DALES: + 2026-01-01: 2444.70 +DONCASTER: + 2026-01-01: 2167.75 +DORSET: + 2026-01-01: 2765.02 +DOVER: + 2026-01-01: 2461.74 +DUDLEY: + 2026-01-01: 2144.84 +DUMFRIES_AND_GALLOWAY: + 2025-01-01: 1454.98 +DUNDEE_CITY: + 2025-01-01: 1605.34 +EALING: + 2026-01-01: 2138.53 +EAST_AYRSHIRE: + 2025-01-01: 1606.44 +EAST_CAMBRIDGESHIRE: + 2026-01-01: 2485.18 +EAST_DEVON: + 2026-01-01: 2595.46 +EAST_DUNBARTONSHIRE: + 2025-01-01: 1599.70 +EAST_HAMPSHIRE: + 2026-01-01: 2343.92 +EAST_HERTFORDSHIRE: + 2026-01-01: 2454.78 +EAST_LINDSEY: + 2026-01-01: 2275.88 +EAST_LOTHIAN: + 2025-01-01: 1579.18 +EAST_RENFREWSHIRE: + 2025-01-01: 1528.44 +EAST_RIDING_OF_YORKSHIRE: + 2026-01-01: 2466.08 +EAST_STAFFORDSHIRE: + 2026-01-01: 2351.34 +EAST_SUFFOLK: + 2026-01-01: 2334.80 +EASTBOURNE: + 2026-01-01: 2654.28 +EASTLEIGH: + 2026-01-01: 2342.21 +ELMBRIDGE: + 2026-01-01: 2557.75 +ENFIELD: + 2026-01-01: 2267.67 +EPPING_FOREST: + 2026-01-01: 2277.21 +EPSOM_AND_EWELL: + 2026-01-01: 2530.84 +EREWASH: + 2026-01-01: 2360.64 +EXETER: + 2026-01-01: 2495.36 +FALKIRK: + 2025-01-01: 1576.77 +FAREHAM: + 2026-01-01: 2270.55 +FENLAND: + 2026-01-01: 2537.74 +FIFE: + 2025-01-01: 1498.76 +FLINTSHIRE: + 2026-01-01: 2376.17 +FOLKESTONE_AND_HYTHE: + 2026-01-01: 2538.75 +FOREST_OF_DEAN: + 2026-01-01: 2443.42 +FYLDE: + 2026-01-01: 2483.94 +GATESHEAD: + 2026-01-01: 2715.81 +GEDLING: + 2026-01-01: 2609.55 +GLASGOW_CITY: + 2025-01-01: 1611.00 +GLOUCESTER: + 2026-01-01: 2355.84 +GOSPORT: + 2026-01-01: 2344.29 +GRAVESHAM: + 2026-01-01: 2408.24 +GREAT_YARMOUTH: + 2026-01-01: 2414.41 +GREENWICH: + 2026-01-01: 2107.86 +GUILDFORD: + 2026-01-01: 2547.30 +GWYNEDD: + 2026-01-01: 2468.77 +HACKNEY: + 2026-01-01: 2060.30 +HALTON: + 2026-01-01: 2366.61 +HAMMERSMITH_AND_FULHAM: + 2026-01-01: 1519.51 +HARBOROUGH: + 2026-01-01: 2389.87 +HARINGEY: + 2026-01-01: 2313.78 +HARLOW: + 2026-01-01: 2298.69 +HARROW: + 2026-01-01: 2511.07 +HART: + 2026-01-01: 2400.02 +HARTLEPOOL: + 2026-01-01: 2560.20 +HASTINGS: + 2026-01-01: 2676.58 +HAVANT: + 2026-01-01: 2320.28 +HAVERING: + 2026-01-01: 2424.66 +HEREFORDSHIRE_COUNTY_OF: + 2026-01-01: 2574.37 +HERTSMERE: + 2026-01-01: 2405.83 +HIGH_PEAK: + 2026-01-01: 2384.66 +HIGHLAND: + 2025-01-01: 1527.09 +HILLINGDON: + 2026-01-01: 2045.46 +HINCKLEY_AND_BOSWORTH: + 2026-01-01: 2382.62 +HORSHAM: + 2026-01-01: 2441.01 +HOUNSLOW: + 2026-01-01: 2185.56 +HUNTINGDONSHIRE: + 2026-01-01: 2556.40 +HYNDBURN: + 2026-01-01: 2465.98 +INVERCLYDE: + 2025-01-01: 1551.30 +IPSWICH: + 2026-01-01: 2468.25 +ISLE_OF_ANGLESEY: + 2026-01-01: 2260.73 +ISLE_OF_WIGHT: + 2026-01-01: 2625.79 +ISLES_OF_SCILLY: + 2026-01-01: 2047.96 +ISLINGTON: + 2026-01-01: 2108.15 +KENSINGTON_AND_CHELSEA: + 2026-01-01: 1666.65 +KINGS_LYNN_AND_WEST_NORFOLK: + 2026-01-01: 2430.13 +KINGSTON_UPON_HULL_CITY_OF: + 2026-01-01: 2295.04 +KINGSTON_UPON_THAMES: + 2026-01-01: 2609.20 +KIRKLEES: + 2026-01-01: 2441.07 +KNOWSLEY: + 2026-01-01: 2488.25 +LAMBETH: + 2026-01-01: 2047.11 +LANCASTER: + 2026-01-01: 2503.19 +LEEDS: + 2026-01-01: 2283.73 +LEICESTER: + 2026-01-01: 2528.75 +LEWES: + 2026-01-01: 2756.17 +LEWISHAM: + 2026-01-01: 2237.33 +LICHFIELD: + 2026-01-01: 2351.90 +LINCOLN: + 2026-01-01: 2323.08 +LIVERPOOL: + 2026-01-01: 2673.59 +LUTON: + 2026-01-01: 2439.93 +MAIDSTONE: + 2026-01-01: 2502.59 +MALDON: + 2026-01-01: 2328.37 +MALVERN_HILLS: + 2026-01-01: 2450.92 +MANCHESTER: + 2026-01-01: 2312.04 +MANSFIELD: + 2026-01-01: 2600.38 +MEDWAY: + 2026-01-01: 2328.55 +MELTON: + 2026-01-01: 2429.67 +MERTHYR_TYDFIL: + 2026-01-01: 2593.60 +MERTON: + 2026-01-01: 2146.76 +MID_DEVON: + 2026-01-01: 2656.41 +MID_SUFFOLK: + 2026-01-01: 2316.88 +MID_SUSSEX: + 2026-01-01: 2474.26 +MIDDLESBROUGH: + 2026-01-01: 2549.16 +MIDLOTHIAN: + 2025-01-01: 1666.20 +MILTON_KEYNES: + 2026-01-01: 2372.21 +MOLE_VALLEY: + 2026-01-01: 2519.86 +MONMOUTHSHIRE: + 2026-01-01: 2416.50 +MORAY: + 2025-01-01: 1573.76 +NA_H_EILEANAN_SIAR: + 2025-01-01: 1387.56 +NEATH_PORT_TALBOT: + 2026-01-01: 2541.39 +NEW_FOREST: + 2026-01-01: 2419.76 +NEWARK_AND_SHERWOOD: + 2026-01-01: 2682.10 +NEWCASTLE_UPON_TYNE: + 2026-01-01: 2542.19 +NEWCASTLE_UNDER_LYME: + 2026-01-01: 2334.25 +NEWHAM: + 2026-01-01: 1944.23 +NEWPORT: + 2026-01-01: 2088.48 +NORTH_AYRSHIRE: + 2025-01-01: 1553.77 +NORTH_DEVON: + 2026-01-01: 2642.21 +NORTH_EAST_DERBYSHIRE: + 2026-01-01: 2466.57 +NORTH_EAST_LINCOLNSHIRE: + 2026-01-01: 2483.85 +NORTH_HERTFORDSHIRE: + 2026-01-01: 2448.52 +NORTH_KESTEVEN: + 2026-01-01: 2337.81 +NORTH_LANARKSHIRE: + 2025-01-01: 1452.86 +NORTH_LINCOLNSHIRE: + 2026-01-01: 2348.46 +NORTH_NORFOLK: + 2026-01-01: 2459.93 +NORTH_SOMERSET: + 2026-01-01: 2491.22 +NORTH_TYNESIDE: + 2026-01-01: 2461.77 +NORTH_WARWICKSHIRE: + 2026-01-01: 2532.10 +NORTH_WEST_LEICESTERSHIRE: + 2026-01-01: 2418.20 +NORTHUMBERLAND: + 2026-01-01: 2596.94 +NORWICH: + 2026-01-01: 2503.44 +NOTTINGHAM: + 2026-01-01: 2755.39 +NUNEATON_AND_BEDWORTH: + 2026-01-01: 2502.16 +OADBY_AND_WIGSTON: + 2026-01-01: 2406.85 +OLDHAM: + 2026-01-01: 2602.23 +ORKNEY_ISLANDS: + 2025-01-01: 1574.60 +OXFORD: + 2026-01-01: 2678.40 +PEMBROKESHIRE: + 2026-01-01: 2166.15 +PENDLE: + 2026-01-01: 2640.18 +PERTH_AND_KINROSS: + 2025-01-01: 1537.04 +PETERBOROUGH: + 2026-01-01: 2293.47 +PLYMOUTH: + 2026-01-01: 2441.85 +PORTSMOUTH: + 2026-01-01: 2291.71 +POWYS: + 2026-01-01: 2351.72 +PRESTON: + 2026-01-01: 2575.74 +READING: + 2026-01-01: 2612.77 +REDBRIDGE: + 2026-01-01: 2294.58 +REDCAR_AND_CLEVELAND: + 2026-01-01: 2544.48 +REDDITCH: + 2026-01-01: 2462.95 +REIGATE_AND_BANSTEAD: + 2026-01-01: 2566.61 +RENFREWSHIRE: + 2025-01-01: 1572.61 +RHONDDA_CYNON_TAF: + 2026-01-01: 2289.43 +RIBBLE_VALLEY: + 2026-01-01: 2386.50 +RICHMOND_UPON_THAMES: + 2026-01-01: 2486.10 +ROCHDALE: + 2026-01-01: 2600.83 +ROCHFORD: + 2026-01-01: 2363.48 +ROSSENDALE: + 2026-01-01: 2519.97 +ROTHER: + 2026-01-01: 2700.95 +ROTHERHAM: + 2026-01-01: 2381.53 +RUGBY: + 2026-01-01: 2483.05 +RUNNYMEDE: + 2026-01-01: 2492.99 +RUSHCLIFFE: + 2026-01-01: 2643.27 +RUSHMOOR: + 2026-01-01: 2320.28 +RUTLAND: + 2026-01-01: 2737.58 +SALFORD: + 2026-01-01: 2594.45 +SANDWELL: + 2026-01-01: 2244.46 +SCOTTISH_BORDERS: + 2025-01-01: 1491.72 +SEFTON: + 2026-01-01: 2583.22 +SEVENOAKS: + 2026-01-01: 2522.36 +SHEFFIELD: + 2026-01-01: 2510.16 +SHETLAND_ISLANDS: + 2025-01-01: 1386.67 +SHROPSHIRE: + 2026-01-01: 2528.18 +SLOUGH: + 2026-01-01: 2414.44 +SOLIHULL: + 2026-01-01: 2197.26 +SOUTH_AYRSHIRE: + 2025-01-01: 1569.41 +SOUTH_CAMBRIDGESHIRE: + 2026-01-01: 2536.25 +SOUTH_DERBYSHIRE: + 2026-01-01: 2339.61 +SOUTH_GLOUCESTERSHIRE: + 2026-01-01: 2550.59 +SOUTH_HAMS: + 2026-01-01: 2615.22 +SOUTH_HOLLAND: + 2026-01-01: 2278.20 +SOUTH_KESTEVEN: + 2026-01-01: 2260.00 +SOUTH_LANARKSHIRE: + 2025-01-01: 1378.85 +SOUTH_NORFOLK: + 2026-01-01: 2482.87 +SOUTH_OXFORDSHIRE: + 2026-01-01: 2597.57 +SOUTH_RIBBLE: + 2026-01-01: 2442.66 +SOUTH_STAFFORDSHIRE: + 2026-01-01: 2313.67 +SOUTH_TYNESIDE: + 2026-01-01: 2434.43 +SOUTHAMPTON: + 2026-01-01: 2381.48 +SOUTHEND_ON_SEA: + 2026-01-01: 2265.17 +SOUTHWARK: + 2026-01-01: 1967.26 +SPELTHORNE: + 2026-01-01: 2526.44 +ST_ALBANS: + 2026-01-01: 2419.22 +ST_HELENS: + 2026-01-01: 2403.38 +STAFFORD: + 2026-01-01: 2302.68 +STAFFORDSHIRE_MOORLANDS: + 2026-01-01: 2338.21 +STEVENAGE: + 2026-01-01: 2391.97 +STIRLING: + 2025-01-01: 1611.78 +STOCKPORT: + 2026-01-01: 2618.90 +STOCKTON_ON_TEES: + 2026-01-01: 2589.83 +STOKE_ON_TRENT: + 2026-01-01: 2183.12 +STRATFORD_ON_AVON: + 2026-01-01: 2484.17 +STROUD: + 2026-01-01: 2491.36 +SUNDERLAND: + 2026-01-01: 2197.14 +SURREY_HEATH: + 2026-01-01: 2585.16 +SUTTON: + 2026-01-01: 2378.64 +SWALE: + 2026-01-01: 2405.65 +SWANSEA: + 2026-01-01: 2238.29 +SWINDON: + 2026-01-01: 2438.10 +TAMESIDE: + 2026-01-01: 2447.21 +TAMWORTH: + 2026-01-01: 2300.76 +TANDRIDGE: + 2026-01-01: 2594.85 +TEIGNBRIDGE: + 2026-01-01: 2643.12 +TELFORD_AND_WREKIN: + 2026-01-01: 2256.26 +TENDRING: + 2026-01-01: 2269.63 +TEST_VALLEY: + 2026-01-01: 2305.52 +TEWKESBURY: + 2026-01-01: 2340.53 +THANET: + 2026-01-01: 2490.90 +THREE_RIVERS: + 2026-01-01: 2422.31 +THURROCK: + 2026-01-01: 2254.68 +TONBRIDGE_AND_MALLING: + 2026-01-01: 2471.83 +TORBAY: + 2026-01-01: 2470.25 +TORFAEN: + 2026-01-01: 2155.68 +TORRIDGE: + 2026-01-01: 2601.42 +TOWER_HAMLETS: + 2026-01-01: 1837.78 +TRAFFORD: + 2026-01-01: 2291.70 +TUNBRIDGE_WELLS: + 2026-01-01: 2446.15 +UTTLESFORD: + 2026-01-01: 2333.82 +VALE_OF_GLAMORGAN: + 2026-01-01: 2231.54 +VALE_OF_WHITE_HORSE: + 2026-01-01: 2577.44 +WAKEFIELD: + 2026-01-01: 2296.89 +WALSALL: + 2026-01-01: 2627.48 +WALTHAM_FOREST: + 2026-01-01: 2386.96 +WANDSWORTH: + 2026-01-01: 1028.21 +WARRINGTON: + 2026-01-01: 2447.61 +WARWICK: + 2026-01-01: 2461.55 +WATFORD: + 2026-01-01: 2446.70 +WAVERLEY: + 2026-01-01: 2604.87 +WEALDEN: + 2026-01-01: 2728.43 +WELWYN_HATFIELD: + 2026-01-01: 2452.26 +WEST_BERKSHIRE: + 2026-01-01: 2505.37 +WEST_DEVON: + 2026-01-01: 2705.33 +WEST_DUNBARTONSHIRE: + 2025-01-01: 1559.86 +WEST_LANCASHIRE: + 2026-01-01: 2456.23 +WEST_LINDSEY: + 2026-01-01: 2354.25 +WEST_LOTHIAN: + 2025-01-01: 1515.45 +WEST_OXFORDSHIRE: + 2026-01-01: 2566.95 +WEST_SUFFOLK: + 2026-01-01: 2351.47 +WESTMINSTER: + 2026-01-01: 1049.55 +WIGAN: + 2026-01-01: 2152.68 +WILTSHIRE: + 2026-01-01: 2571.78 +WINCHESTER: + 2026-01-01: 2359.76 +WINDSOR_AND_MAIDENHEAD: + 2026-01-01: 1952.62 +WIRRAL: + 2026-01-01: 2500.59 +WOKING: + 2026-01-01: 2598.04 +WOKINGHAM: + 2026-01-01: 2497.70 +WOLVERHAMPTON: + 2026-01-01: 2538.99 +WORCESTER: + 2026-01-01: 2405.81 +WORTHING: + 2026-01-01: 2456.13 +WREXHAM: + 2026-01-01: 2308.88 +WYCHAVON: + 2026-01-01: 2373.19 +WYRE: + 2026-01-01: 2460.53 +WYRE_FOREST: + 2026-01-01: 2512.47 +YORK: + 2026-01-01: 2287.15 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml new file mode 100644 index 0000000000..f6b6a001b6 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml @@ -0,0 +1,26 @@ +description: Statutory council tax band proportions for England, relative to Band D. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: England council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 5 + href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 +A: + 1993-04-01: 0.666666667 # 2/3 +B: + 1993-04-01: 0.777777778 # 7/9 +C: + 1993-04-01: 0.888888889 # 8/9 +D: + 1993-04-01: 1.000000000 # 1/1 +E: + 1993-04-01: 1.222222222 # 11/9 +F: + 1993-04-01: 1.444444444 # 13/9 +G: + 1993-04-01: 1.666666667 # 5/3 +H: + 1993-04-01: 2.000000000 # 2/1 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml new file mode 100644 index 0000000000..8b8404de9e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml @@ -0,0 +1,28 @@ +description: Statutory council tax band proportions for Scotland, relative to Band D. Bands E to H were increased from 1 April 2017. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: Scotland council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 5 + href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 + - title: The Council Tax (Substitution of Proportion) (Scotland) Order 2016 + href: https://www.legislation.gov.uk/ssi/2016/123/made +A: + 2017-04-01: 0.666666667 # 2/3 +B: + 2017-04-01: 0.777777778 # 7/9 +C: + 2017-04-01: 0.888888889 # 8/9 +D: + 2017-04-01: 1.000000000 # 1/1 +E: + 2017-04-01: 1.313888889 # 473/360 +F: + 2017-04-01: 1.625000000 # 13/8 +G: + 2017-04-01: 1.958333333 # 47/24 +H: + 2017-04-01: 2.450000000 # 49/20 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml new file mode 100644 index 0000000000..d2a2ebc330 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml @@ -0,0 +1,30 @@ +description: Statutory council tax band proportions for Wales, relative to Band D. Wales has nine bands (A to I) from 1 April 2005. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: Wales council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 5 + href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 + - title: The Council Tax (Valuation Bands) (Wales) Order 2003 + href: https://www.legislation.gov.uk/wsi/2003/3046/made +A: + 2005-04-01: 0.666666667 # 2/3 +B: + 2005-04-01: 0.777777778 # 7/9 +C: + 2005-04-01: 0.888888889 # 8/9 +D: + 2005-04-01: 1.000000000 # 1/1 +E: + 2005-04-01: 1.222222222 # 11/9 +F: + 2005-04-01: 1.444444444 # 13/9 +G: + 2005-04-01: 1.666666667 # 5/3 +H: + 2005-04-01: 2.000000000 # 2/1 +I: + 2005-04-01: 2.333333333 # 7/3 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml new file mode 100644 index 0000000000..4f0fae6fe3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml @@ -0,0 +1,239 @@ +- name: England Band A is 2/3 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: A + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 1668.39 + +- name: England Band B is 7/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: B + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 1946.46 + +- name: England Band C is 8/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: C + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2224.52 + +- name: England Band D is 1/1 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 + +- name: England Band E is 11/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: E + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 3058.72 + +- name: England Band F is 13/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: F + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 3614.85 + +- name: England Band G is 5/3 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: G + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 4170.98 + +- name: England Band H is 2/1 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: H + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 5005.18 + +- name: Wales Band I (the ninth, Wales-only band) is 21/9 of Band D + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: I + output: + council_tax_band_d_amount: 2013.18 + simulated_council_tax: 4697.42 + +- name: "Band D identity: liability equals the Band D amount in Cardiff" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: D + output: + council_tax_band_d_amount: 2013.18 + simulated_council_tax: 2013.18 + +- name: Scotland Band H uses the 882/360 multiplier from April 2017 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1563.51 + simulated_council_tax: 3830.6 + +- name: "Band D identity: liability equals the Band D amount in Maidstone" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py new file mode 100644 index 0000000000..725ac12ca2 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class council_tax_band_d_amount(Variable): + value_type = float + entity = Household + label = "Band D council tax amount" + documentation = ( + "The Band D council tax amount set by the household's local authority, " + "before discounts, exemptions or Council Tax Reduction. Only one " + "year of data is loaded (2026-27 for England and Wales, 2025-26 for " + "Scotland), so earlier years are held flat backwards." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + + def formula(household, period, parameters): + la = household("local_authority", period).decode_to_str() + amounts = parameters(period).gov.local_authorities.council_tax.band_d.amounts + # Dict lookup rather than bracket indexing: Northern Ireland (domestic + # rates, not council tax) and six post-2021 unitary authorities absent + # from the LocalAuthority enum have no Band D amount, and should return + # zero rather than raise. + return np.array([amounts._children.get(a, 0) for a in la], dtype=float) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py new file mode 100644 index 0000000000..9a3996f543 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class simulated_council_tax(Variable): + value_type = float + entity = Household + label = "Simulated gross council tax" + documentation = ( + "Gross annual council tax liability computed as the local authority's " + "Band D amount multiplied by the statutory band ratio. Before " + "discounts, exemptions, premiums and Council Tax Reduction." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + + def formula(household, period, parameters): + band_d = household("council_tax_band_d_amount", period) + band = household("council_tax_band", period).decode_to_str() + country = household("country", period).decode_to_str() + ratios = parameters(period).gov.local_authorities.council_tax.band_ratios + + ratio = np.ones_like(band_d, dtype=float) + for key, node in ( + ("ENGLAND", ratios.england), + ("WALES", ratios.wales), + ("SCOTLAND", ratios.scotland), + ): + table = node._children + values = np.array([float(table.get(b, 1)) for b in band], dtype=float) + ratio = np.where(country == key, values, ratio) + + # Northern Ireland uses domestic rates, not council tax. + return np.where(country == "NORTHERN_IRELAND", 0, band_d * ratio) From 9b6909c61ca09f4eb24f9f0e5c78394a6ab523b6 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sat, 26 Sep 2026 16:57:34 +0100 Subject: [PATCH 008/141] Cover all 350 local authorities and both fiscal 2025 and 2026 Band D levels Review follow-up on the four data-coverage items. Enum coverage: add the six April 2023 unitary authorities (Cumberland, North Northamptonshire, North Yorkshire, Somerset, West Northamptonshire, Westmorland and Furness) to LocalAuthority. All 350 Band D rows now load, up from 344. Vintages: carry fiscal 2025 and 2026 for England, Wales and Scotland. England comes from MHCLG Table 10 line 17 in both the 2026-27 and the revised 2025-26 releases; the 2025-26 release's current-year column reproduces the 2026-27 release's previous-year column, and every England and Wales 2026 value reproduces the figures already in the file. Wales comes from the Welsh Government council tax levels data tables, Table 1 "Overall average band D". Scotland comes from the Scottish Government Band D council tax 1996-97 to 2026-27 dataset, whose 2025-26 column reproduces the Scottish values already in the file. References: cite the specific workbooks rather than the statistics collection page, and state the basis. For England and Wales this is the area figure, so it includes the county, police, fire, combined authority and parish or community council precepts; for Scotland it excludes the separately billed water and waste water charges. The variable documentation and the stale "six authorities absent from the enum" comment are corrected to match. Scotland ratios: SSI 2016/368 (not 2016/123, which is the capital finance regulations and was cited in error) substitutes 240:280:320:360:473:585: 705:882 into section 74(1) for financial years from 1 April 2017. Bands A to D are unchanged from the original 6:7:8:9 proportion, so they are dated from 1993-04-01; bands E to H keep the 2017 step and gain their original 11/9, 13/9, 15/9 and 18/9 values from 1993-04-01, so no band is undefined before 2017. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/council-tax-computed-liability.md | 2 +- .../council_tax/band_d/amounts.yaml | 382 +++++++++++++++++- .../council_tax/band_ratios/scotland.yaml | 28 +- .../council_tax/simulated_council_tax.yaml | 124 +++++- .../council_tax/council_tax_band_d_amount.py | 18 +- .../household/demographic/locations.py | 2 + 6 files changed, 525 insertions(+), 31 deletions(-) diff --git a/changelog.d/council-tax-computed-liability.md b/changelog.d/council-tax-computed-liability.md index 2747986d00..f91130b7ac 100644 --- a/changelog.d/council-tax-computed-liability.md +++ b/changelog.d/council-tax-computed-liability.md @@ -1 +1 @@ -Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. +Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. Band D amounts cover all 350 April 2023 local authorities across England, Wales and Scotland for fiscal 2025 and 2026. diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml index 7d69ffc825..ece88c9e18 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml @@ -1,4 +1,4 @@ -description: Band D council tax amount set by each local authority, before any discounts, exemptions or Council Tax Reduction. +description: Band D council tax amount set by each local authority, before any discounts, exemptions or Council Tax Reduction. For England and Wales this is the area figure for the billing authority, so it includes the county, police, fire, combined authority and parish or community council precepts as well as the billing authority's own charge; for Scotland it is the council's Band D rate, which excludes the separately billed water and waste water charges. metadata: economy: false propagate_metadata_to_children: true @@ -6,697 +6,1061 @@ metadata: period: year label: Local authority Band D council tax amount reference: - - title: Council tax levels set by local authorities in England 2026 to 2027 - href: https://www.gov.uk/government/collections/council-tax-statistics - - title: Council tax levels April 2026 to March 2027 (Wales) - href: https://www.gov.wales/council-tax-levels-april-2026-march-2027-html - - title: Council Tax datasets (Scotland) - href: https://www.gov.scot/publications/council-tax-datasets/ + - title: "England 2026-27: Council tax levels set by local authorities in England, Table 10 local authority level data, sheet Data_Billing, line 17 (average Band D 2 adult equivalent council tax for the area of the billing authority, including both local and major precepts)" + href: https://assets.publishing.service.gov.uk/media/6a02eeeccd2e0e8b5b20b449/Table_10_2026-27.ods + - title: "England 2025-26: Council tax levels set by local authorities in England, Table 10 local authority level data (revised), sheet Data_Billing, line 17 (same area basis)" + href: https://assets.publishing.service.gov.uk/media/6a02f143e71c4cdf4026ba48/Table_10_2025-26.ods + - title: "Wales 2026-27 and 2025-26: Council tax levels April 2026 to March 2027, associated data tables, Table 1 column 'Overall average band D' (county council, community council and police authority elements combined); Table 2 carries the 2025-26 figure" + href: https://gov.wales/sites/default/files/statistics-and-research/2026-03/council-tax-levels-april-2026-march-2027-153478.xlsx + - title: "Scotland 2026-27 and 2025-26: Band D council tax 1996-97 to 2026-27, Council Tax datasets (excludes water and waste water charges)" + href: https://www.gov.scot/binaries/content/documents/govscot/publications/statistics/2019/04/council-tax-datasets/documents/band-d/band-d-council-tax-1996-97-to-2019-20-data/band-d-council-tax-1996-97-to-2019-20-data/govscot%3Adocument/CTAS%2B2026%2B-%2BCouncil%2BTax%2BAssumptions%2B-%2BBand%2BD%2BCouncil%2BTax%2Brates%2Bby%2Byear%2B-%2B1996-97%2Bto%2B2026-27.xlsx ABERDEEN_CITY: 2025-01-01: 1636.27 + 2026-01-01: 1747.54 ABERDEENSHIRE: 2025-01-01: 1532.76 + 2026-01-01: 1686.04 ADUR: + 2025-01-01: 2432.50 2026-01-01: 2548.28 AMBER_VALLEY: + 2025-01-01: 2297.98 2026-01-01: 2407.69 ANGUS: 2025-01-01: 1461.52 + 2026-01-01: 1598.65 ARGYLL_AND_BUTE: 2025-01-01: 1625.64 + 2026-01-01: 1783.33 ARUN: + 2025-01-01: 2371.09 2026-01-01: 2487.24 ASHFIELD: + 2025-01-01: 2506.63 2026-01-01: 2608.76 ASHFORD: + 2025-01-01: 2311.19 2026-01-01: 2410.16 BABERGH: + 2025-01-01: 2233.16 2026-01-01: 2341.55 BARKING_AND_DAGENHAM: + 2025-01-01: 2098.14 2026-01-01: 2198.51 BARNET: + 2025-01-01: 2035.52 2026-01-01: 2132.60 BARNSLEY: + 2025-01-01: 2241.35 2026-01-01: 2325.96 BASILDON: + 2025-01-01: 2244.63 2026-01-01: 2327.24 BASINGSTOKE_AND_DEANE: + 2025-01-01: 2147.87 2026-01-01: 2255.21 BASSETLAW: + 2025-01-01: 2539.53 2026-01-01: 2644.95 BATH_AND_NORTH_EAST_SOMERSET: + 2025-01-01: 2265.80 2026-01-01: 2383.42 BEDFORD: + 2025-01-01: 2350.94 2026-01-01: 2471.74 BEXLEY: + 2025-01-01: 2258.03 2026-01-01: 2366.36 BIRMINGHAM: + 2025-01-01: 2245.41 2026-01-01: 2362.90 BLABY: + 2025-01-01: 2406.77 2026-01-01: 2490.22 BLACKBURN_WITH_DARWEN: + 2025-01-01: 2337.17 2026-01-01: 2455.27 BLACKPOOL: + 2025-01-01: 2392.21 2026-01-01: 2513.22 BLAENAU_GWENT: + 2025-01-01: 2428.43 2026-01-01: 2531.26 BOLSOVER: + 2025-01-01: 2447.51 2026-01-01: 2559.41 BOLTON: + 2025-01-01: 2266.53 2026-01-01: 2399.74 BOSTON: + 2025-01-01: 2236.62 2026-01-01: 2308.73 BOURNEMOUTH_CHRISTCHURCH_AND_POOLE: + 2025-01-01: 2264.78 2026-01-01: 2435.58 BRACKNELL_FOREST: + 2025-01-01: 2155.21 2026-01-01: 2264.80 BRADFORD: + 2025-01-01: 2246.15 2026-01-01: 2360.73 BRAINTREE: + 2025-01-01: 2194.67 2026-01-01: 2280.34 BRECKLAND: + 2025-01-01: 2328.59 2026-01-01: 2443.67 BRENT: + 2025-01-01: 2133.15 2026-01-01: 2235.27 BRENTWOOD: + 2025-01-01: 2166.72 2026-01-01: 2258.14 BRIDGEND: + 2025-01-01: 2357.63 2026-01-01: 2477.99 BRIGHTON_AND_HOVE: + 2025-01-01: 2456.78 2026-01-01: 2580.54 BRISTOL_CITY_OF: + 2025-01-01: 2583.89 2026-01-01: 2713.68 BROADLAND: + 2025-01-01: 2327.99 2026-01-01: 2437.78 BROMLEY: + 2025-01-01: 2042.46 2026-01-01: 2140.04 BROMSGROVE: + 2025-01-01: 2302.72 2026-01-01: 2478.65 BROXBOURNE: + 2025-01-01: 2198.11 2026-01-01: 2306.43 BROXTOWE: + 2025-01-01: 2516.20 2026-01-01: 2618.21 BUCKINGHAMSHIRE: + 2025-01-01: 2401.53 2026-01-01: 2526.58 BURNLEY: + 2025-01-01: 2455.83 2026-01-01: 2549.42 BURY: + 2025-01-01: 2414.58 2026-01-01: 2555.15 CAERPHILLY: + 2025-01-01: 1956.20 2026-01-01: 2082.88 CALDERDALE: + 2025-01-01: 2302.33 2026-01-01: 2420.15 CAMBRIDGE: + 2025-01-01: 2355.41 2026-01-01: 2467.02 CAMDEN: + 2025-01-01: 2106.69 2026-01-01: 2207.55 CANNOCK_CHASE: + 2025-01-01: 2282.30 2026-01-01: 2375.83 CANTERBURY: + 2025-01-01: 2321.73 2026-01-01: 2419.33 CARDIFF: + 2025-01-01: 1926.18 2026-01-01: 2013.18 CARMARTHENSHIRE: + 2025-01-01: 2232.06 2026-01-01: 2351.20 CASTLE_POINT: + 2025-01-01: 2227.21 2026-01-01: 2318.63 CENTRAL_BEDFORDSHIRE: + 2025-01-01: 2394.14 2026-01-01: 2516.16 CEREDIGION: + 2025-01-01: 2296.09 2026-01-01: 2419.45 CHARNWOOD: + 2025-01-01: 2327.50 2026-01-01: 2405.64 CHELMSFORD: + 2025-01-01: 2206.97 2026-01-01: 2300.18 CHELTENHAM: + 2025-01-01: 2258.28 2026-01-01: 2369.52 CHERWELL: + 2025-01-01: 2463.87 2026-01-01: 2582.96 CHESHIRE_EAST: + 2025-01-01: 2332.61 2026-01-01: 2454.87 CHESHIRE_WEST_AND_CHESTER: + 2025-01-01: 2392.03 2026-01-01: 2517.33 CHESTERFIELD: + 2025-01-01: 2233.12 2026-01-01: 2339.49 CHICHESTER: + 2025-01-01: 2346.40 2026-01-01: 2469.68 CHORLEY: + 2025-01-01: 2342.85 2026-01-01: 2430.23 CITY_OF_EDINBURGH: 2025-01-01: 1563.51 + 2026-01-01: 1626.05 CITY_OF_LONDON: + 2025-01-01: 1274.07 2026-01-01: 1329.56 CLACKMANNANSHIRE: 2025-01-01: 1594.38 + 2026-01-01: 1683.67 COLCHESTER: + 2025-01-01: 2192.08 2026-01-01: 2282.85 CONWY: + 2025-01-01: 2320.83 2026-01-01: 2472.82 CORNWALL: + 2025-01-01: 2459.92 2026-01-01: 2590.93 COTSWOLD: + 2025-01-01: 2276.70 2026-01-01: 2391.65 COUNTY_DURHAM: + 2025-01-01: 2551.17 2026-01-01: 2622.15 COVENTRY: + 2025-01-01: 2413.68 2026-01-01: 2516.72 CRAWLEY: + 2025-01-01: 2306.49 2026-01-01: 2418.46 CROYDON: + 2025-01-01: 2480.48 2026-01-01: 2599.91 +CUMBERLAND: + 2025-01-01: 2393.90 + 2026-01-01: 2511.32 DACORUM: + 2025-01-01: 2294.23 2026-01-01: 2408.51 DARLINGTON: + 2025-01-01: 2371.60 2026-01-01: 2493.83 DARTFORD: + 2025-01-01: 2280.55 2026-01-01: 2375.15 DENBIGHSHIRE: + 2025-01-01: 2217.08 2026-01-01: 2339.03 DERBY: + 2025-01-01: 2195.75 2026-01-01: 2306.00 DERBYSHIRE_DALES: + 2025-01-01: 2332.42 2026-01-01: 2444.70 DONCASTER: + 2025-01-01: 2110.50 2026-01-01: 2167.75 DORSET: + 2025-01-01: 2630.30 2026-01-01: 2765.02 DOVER: + 2025-01-01: 2364.45 2026-01-01: 2461.74 DUDLEY: + 2025-01-01: 2038.57 2026-01-01: 2144.84 DUMFRIES_AND_GALLOWAY: 2025-01-01: 1454.98 + 2026-01-01: 1578.65 DUNDEE_CITY: 2025-01-01: 1605.34 + 2026-01-01: 1729.69 EALING: + 2025-01-01: 2041.02 2026-01-01: 2138.53 EAST_AYRSHIRE: 2025-01-01: 1606.44 + 2026-01-01: 1717.28 EAST_CAMBRIDGESHIRE: + 2025-01-01: 2374.13 2026-01-01: 2485.18 EAST_DEVON: + 2025-01-01: 2461.40 2026-01-01: 2595.46 EAST_DUNBARTONSHIRE: 2025-01-01: 1599.70 + 2026-01-01: 1751.67 EAST_HAMPSHIRE: + 2025-01-01: 2231.64 2026-01-01: 2343.92 EAST_HERTFORDSHIRE: + 2025-01-01: 2339.63 2026-01-01: 2454.78 EAST_LINDSEY: + 2025-01-01: 2201.57 2026-01-01: 2275.88 EAST_LOTHIAN: 2025-01-01: 1579.18 + 2026-01-01: 1697.62 EAST_RENFREWSHIRE: 2025-01-01: 1528.44 + 2026-01-01: 1620.15 EAST_RIDING_OF_YORKSHIRE: + 2025-01-01: 2343.55 2026-01-01: 2466.08 EAST_STAFFORDSHIRE: + 2025-01-01: 2257.48 2026-01-01: 2351.34 EAST_SUFFOLK: + 2025-01-01: 2222.36 2026-01-01: 2334.80 EASTBOURNE: + 2025-01-01: 2532.49 2026-01-01: 2654.28 EASTLEIGH: + 2025-01-01: 2227.68 2026-01-01: 2342.21 ELMBRIDGE: + 2025-01-01: 2442.92 2026-01-01: 2557.75 ENFIELD: + 2025-01-01: 2164.02 2026-01-01: 2267.67 EPPING_FOREST: + 2025-01-01: 2186.69 2026-01-01: 2277.21 EPSOM_AND_EWELL: + 2025-01-01: 2416.84 2026-01-01: 2530.84 EREWASH: + 2025-01-01: 2260.06 2026-01-01: 2360.64 EXETER: + 2025-01-01: 2379.90 2026-01-01: 2495.36 FALKIRK: 2025-01-01: 1576.77 + 2026-01-01: 1715.09 FAREHAM: + 2025-01-01: 2164.55 2026-01-01: 2270.55 FENLAND: + 2025-01-01: 2432.81 2026-01-01: 2537.74 FIFE: 2025-01-01: 1498.76 + 2026-01-01: 1573.70 FLINTSHIRE: + 2025-01-01: 2246.47 2026-01-01: 2376.17 FOLKESTONE_AND_HYTHE: + 2025-01-01: 2437.35 2026-01-01: 2538.75 FOREST_OF_DEAN: + 2025-01-01: 2326.88 2026-01-01: 2443.42 FYLDE: + 2025-01-01: 2387.78 2026-01-01: 2483.94 GATESHEAD: + 2025-01-01: 2578.46 2026-01-01: 2715.81 GEDLING: + 2025-01-01: 2507.09 2026-01-01: 2609.55 GLASGOW_CITY: 2025-01-01: 1611.00 + 2026-01-01: 1706.00 GLOUCESTER: + 2025-01-01: 2246.16 2026-01-01: 2355.84 GOSPORT: + 2025-01-01: 2236.14 2026-01-01: 2344.29 GRAVESHAM: + 2025-01-01: 2313.06 2026-01-01: 2408.24 GREAT_YARMOUTH: + 2025-01-01: 2305.13 2026-01-01: 2414.41 GREENWICH: + 2025-01-01: 2011.81 2026-01-01: 2107.86 GUILDFORD: + 2025-01-01: 2428.89 2026-01-01: 2547.30 GWYNEDD: + 2025-01-01: 2340.22 2026-01-01: 2468.77 HACKNEY: + 2025-01-01: 1966.51 2026-01-01: 2060.30 HALTON: + 2025-01-01: 2249.34 2026-01-01: 2366.61 HAMMERSMITH_AND_FULHAM: + 2025-01-01: 1451.42 2026-01-01: 1519.51 HARBOROUGH: + 2025-01-01: 2324.96 2026-01-01: 2389.87 HARINGEY: + 2025-01-01: 2207.94 2026-01-01: 2313.78 HARLOW: + 2025-01-01: 2216.43 2026-01-01: 2298.69 HARROW: + 2025-01-01: 2395.86 2026-01-01: 2511.07 HART: + 2025-01-01: 2285.15 2026-01-01: 2400.02 HARTLEPOOL: + 2025-01-01: 2498.53 2026-01-01: 2560.20 HASTINGS: + 2025-01-01: 2554.14 2026-01-01: 2676.58 HAVANT: + 2025-01-01: 2212.89 2026-01-01: 2320.28 HAVERING: + 2025-01-01: 2313.55 2026-01-01: 2424.66 HEREFORDSHIRE_COUNTY_OF: + 2025-01-01: 2449.17 2026-01-01: 2574.37 HERTSMERE: + 2025-01-01: 2289.22 2026-01-01: 2405.83 HIGH_PEAK: + 2025-01-01: 2275.52 2026-01-01: 2384.66 HIGHLAND: 2025-01-01: 1527.09 + 2026-01-01: 1633.99 HILLINGDON: + 2025-01-01: 1952.38 2026-01-01: 2045.46 HINCKLEY_AND_BOSWORTH: + 2025-01-01: 2304.14 2026-01-01: 2382.62 HORSHAM: + 2025-01-01: 2321.54 2026-01-01: 2441.01 HOUNSLOW: + 2025-01-01: 2085.82 2026-01-01: 2185.56 HUNTINGDONSHIRE: + 2025-01-01: 2439.67 2026-01-01: 2556.40 HYNDBURN: + 2025-01-01: 2380.02 2026-01-01: 2465.98 INVERCLYDE: 2025-01-01: 1551.30 + 2026-01-01: 1673.85 IPSWICH: + 2025-01-01: 2358.54 2026-01-01: 2468.25 ISLE_OF_ANGLESEY: + 2025-01-01: 2143.94 2026-01-01: 2260.73 ISLE_OF_WIGHT: + 2025-01-01: 2493.36 2026-01-01: 2625.79 ISLES_OF_SCILLY: + 2025-01-01: 1950.03 2026-01-01: 2047.96 ISLINGTON: + 2025-01-01: 2012.10 2026-01-01: 2108.15 KENSINGTON_AND_CHELSEA: + 2025-01-01: 1591.59 2026-01-01: 1666.65 KINGS_LYNN_AND_WEST_NORFOLK: + 2025-01-01: 2318.63 2026-01-01: 2430.13 KINGSTON_UPON_HULL_CITY_OF: + 2025-01-01: 2182.52 2026-01-01: 2295.04 KINGSTON_UPON_THAMES: + 2025-01-01: 2489.34 2026-01-01: 2609.20 KIRKLEES: + 2025-01-01: 2321.76 2026-01-01: 2441.07 KNOWSLEY: + 2025-01-01: 2371.28 2026-01-01: 2488.25 LAMBETH: + 2025-01-01: 1953.95 2026-01-01: 2047.11 LANCASTER: + 2025-01-01: 2407.50 2026-01-01: 2503.19 LEEDS: + 2025-01-01: 2172.39 2026-01-01: 2283.73 LEICESTER: + 2025-01-01: 2407.72 2026-01-01: 2528.75 LEWES: + 2025-01-01: 2627.39 2026-01-01: 2756.17 LEWISHAM: + 2025-01-01: 2135.13 2026-01-01: 2237.33 LICHFIELD: + 2025-01-01: 2259.21 2026-01-01: 2351.90 LINCOLN: + 2025-01-01: 2260.98 2026-01-01: 2323.08 LIVERPOOL: + 2025-01-01: 2546.45 2026-01-01: 2673.59 LUTON: + 2025-01-01: 2320.43 2026-01-01: 2439.93 MAIDSTONE: + 2025-01-01: 2403.78 2026-01-01: 2502.59 MALDON: + 2025-01-01: 2232.83 2026-01-01: 2328.37 MALVERN_HILLS: + 2025-01-01: 2280.82 2026-01-01: 2450.92 MANCHESTER: + 2025-01-01: 2183.03 2026-01-01: 2312.04 MANSFIELD: + 2025-01-01: 2498.40 2026-01-01: 2600.38 MEDWAY: + 2025-01-01: 2215.38 2026-01-01: 2328.55 MELTON: + 2025-01-01: 2350.68 2026-01-01: 2429.67 MERTHYR_TYDFIL: + 2025-01-01: 2464.01 2026-01-01: 2593.60 MERTON: + 2025-01-01: 2094.43 2026-01-01: 2146.76 MID_DEVON: + 2025-01-01: 2521.17 2026-01-01: 2656.41 MID_SUFFOLK: + 2025-01-01: 2208.43 2026-01-01: 2316.88 MID_SUSSEX: + 2025-01-01: 2356.49 2026-01-01: 2474.26 MIDDLESBROUGH: + 2025-01-01: 2487.65 2026-01-01: 2549.16 MIDLOTHIAN: 2025-01-01: 1666.20 + 2026-01-01: 1816.16 MILTON_KEYNES: + 2025-01-01: 2257.98 2026-01-01: 2372.21 MOLE_VALLEY: + 2025-01-01: 2405.75 2026-01-01: 2519.86 MONMOUTHSHIRE: + 2025-01-01: 2277.19 2026-01-01: 2416.50 MORAY: 2025-01-01: 1573.76 + 2026-01-01: 1731.14 NA_H_EILEANAN_SIAR: 2025-01-01: 1387.56 + 2026-01-01: 1505.50 NEATH_PORT_TALBOT: + 2025-01-01: 2440.78 2026-01-01: 2541.39 NEW_FOREST: + 2025-01-01: 2303.37 2026-01-01: 2419.76 NEWARK_AND_SHERWOOD: + 2025-01-01: 2582.22 2026-01-01: 2682.10 -NEWCASTLE_UPON_TYNE: - 2026-01-01: 2542.19 NEWCASTLE_UNDER_LYME: + 2025-01-01: 2244.85 2026-01-01: 2334.25 +NEWCASTLE_UPON_TYNE: + 2025-01-01: 2413.15 + 2026-01-01: 2542.19 NEWHAM: + 2025-01-01: 1855.96 2026-01-01: 1944.23 NEWPORT: + 2025-01-01: 1983.97 2026-01-01: 2088.48 NORTH_AYRSHIRE: 2025-01-01: 1553.77 + 2026-01-01: 1685.84 NORTH_DEVON: + 2025-01-01: 2514.66 2026-01-01: 2642.21 NORTH_EAST_DERBYSHIRE: + 2025-01-01: 2354.93 2026-01-01: 2466.57 NORTH_EAST_LINCOLNSHIRE: + 2025-01-01: 2371.01 2026-01-01: 2483.85 NORTH_HERTFORDSHIRE: + 2025-01-01: 2332.61 2026-01-01: 2448.52 NORTH_KESTEVEN: + 2025-01-01: 2261.06 2026-01-01: 2337.81 NORTH_LANARKSHIRE: 2025-01-01: 1452.86 + 2026-01-01: 1554.56 NORTH_LINCOLNSHIRE: + 2025-01-01: 2238.09 2026-01-01: 2348.46 NORTH_NORFOLK: + 2025-01-01: 2341.13 2026-01-01: 2459.93 +NORTH_NORTHAMPTONSHIRE: + 2025-01-01: 2301.93 + 2026-01-01: 2423.96 NORTH_SOMERSET: + 2025-01-01: 2294.94 2026-01-01: 2491.22 NORTH_TYNESIDE: + 2025-01-01: 2336.45 2026-01-01: 2461.77 NORTH_WARWICKSHIRE: + 2025-01-01: 2431.78 2026-01-01: 2532.10 NORTH_WEST_LEICESTERSHIRE: + 2025-01-01: 2338.10 2026-01-01: 2418.20 +NORTH_YORKSHIRE: + 2025-01-01: 2418.26 + 2026-01-01: 2544.34 NORTHUMBERLAND: + 2025-01-01: 2464.63 2026-01-01: 2596.94 NORWICH: + 2025-01-01: 2391.59 2026-01-01: 2503.44 NOTTINGHAM: + 2025-01-01: 2656.19 2026-01-01: 2755.39 NUNEATON_AND_BEDWORTH: + 2025-01-01: 2398.13 2026-01-01: 2502.16 OADBY_AND_WIGSTON: + 2025-01-01: 2330.02 2026-01-01: 2406.85 OLDHAM: + 2025-01-01: 2459.40 2026-01-01: 2602.23 ORKNEY_ISLANDS: 2025-01-01: 1574.60 + 2026-01-01: 1669.07 OXFORD: + 2025-01-01: 2557.13 2026-01-01: 2678.40 PEMBROKESHIRE: + 2025-01-01: 2059.82 2026-01-01: 2166.15 PENDLE: + 2025-01-01: 2535.19 2026-01-01: 2640.18 PERTH_AND_KINROSS: 2025-01-01: 1537.04 + 2026-01-01: 1673.84 PETERBOROUGH: + 2025-01-01: 2185.72 2026-01-01: 2293.47 PLYMOUTH: + 2025-01-01: 2325.42 2026-01-01: 2441.85 PORTSMOUTH: + 2025-01-01: 2180.92 2026-01-01: 2291.71 POWYS: + 2025-01-01: 2234.44 2026-01-01: 2351.72 PRESTON: + 2025-01-01: 2477.85 2026-01-01: 2575.74 READING: + 2025-01-01: 2487.11 2026-01-01: 2612.77 REDBRIDGE: + 2025-01-01: 2189.67 2026-01-01: 2294.58 REDCAR_AND_CLEVELAND: + 2025-01-01: 2424.47 2026-01-01: 2544.48 REDDITCH: + 2025-01-01: 2289.33 2026-01-01: 2462.95 REIGATE_AND_BANSTEAD: + 2025-01-01: 2459.20 2026-01-01: 2566.61 RENFREWSHIRE: 2025-01-01: 1572.61 + 2026-01-01: 1690.56 RHONDDA_CYNON_TAF: + 2025-01-01: 2190.10 2026-01-01: 2289.43 RIBBLE_VALLEY: + 2025-01-01: 2299.58 2026-01-01: 2386.50 RICHMOND_UPON_THAMES: + 2025-01-01: 2372.07 2026-01-01: 2486.10 ROCHDALE: + 2025-01-01: 2458.09 2026-01-01: 2600.83 ROCHFORD: + 2025-01-01: 2268.00 2026-01-01: 2363.48 ROSSENDALE: + 2025-01-01: 2424.37 2026-01-01: 2519.97 ROTHER: + 2025-01-01: 2561.29 2026-01-01: 2700.95 ROTHERHAM: + 2025-01-01: 2283.26 2026-01-01: 2381.53 RUGBY: + 2025-01-01: 2378.04 2026-01-01: 2483.05 RUNNYMEDE: + 2025-01-01: 2380.06 2026-01-01: 2492.99 RUSHCLIFFE: + 2025-01-01: 2531.50 2026-01-01: 2643.27 RUSHMOOR: + 2025-01-01: 2212.82 2026-01-01: 2320.28 RUTLAND: + 2025-01-01: 2671.01 2026-01-01: 2737.58 SALFORD: + 2025-01-01: 2452.02 2026-01-01: 2594.45 SANDWELL: + 2025-01-01: 2133.45 2026-01-01: 2244.46 SCOTTISH_BORDERS: 2025-01-01: 1491.72 + 2026-01-01: 1618.52 SEFTON: + 2025-01-01: 2460.92 2026-01-01: 2583.22 SEVENOAKS: + 2025-01-01: 2419.44 2026-01-01: 2522.36 SHEFFIELD: + 2025-01-01: 2388.76 2026-01-01: 2510.16 SHETLAND_ISLANDS: 2025-01-01: 1386.67 + 2026-01-01: 1487.90 SHROPSHIRE: + 2025-01-01: 2318.80 2026-01-01: 2528.18 SLOUGH: + 2025-01-01: 2298.55 2026-01-01: 2414.44 SOLIHULL: + 2025-01-01: 2087.81 2026-01-01: 2197.26 +SOMERSET: + 2025-01-01: 2438.62 + 2026-01-01: 2560.59 SOUTH_AYRSHIRE: 2025-01-01: 1569.41 + 2026-01-01: 1694.96 SOUTH_CAMBRIDGESHIRE: + 2025-01-01: 2414.52 2026-01-01: 2536.25 SOUTH_DERBYSHIRE: + 2025-01-01: 2231.86 2026-01-01: 2339.61 SOUTH_GLOUCESTERSHIRE: + 2025-01-01: 2427.90 2026-01-01: 2550.59 SOUTH_HAMS: + 2025-01-01: 2491.75 2026-01-01: 2615.22 SOUTH_HOLLAND: + 2025-01-01: 2205.31 2026-01-01: 2278.20 SOUTH_KESTEVEN: + 2025-01-01: 2186.95 2026-01-01: 2260.00 SOUTH_LANARKSHIRE: 2025-01-01: 1378.85 + 2026-01-01: 1468.47 SOUTH_NORFOLK: + 2025-01-01: 2367.89 2026-01-01: 2482.87 SOUTH_OXFORDSHIRE: + 2025-01-01: 2474.44 2026-01-01: 2597.57 SOUTH_RIBBLE: + 2025-01-01: 2352.12 2026-01-01: 2442.66 SOUTH_STAFFORDSHIRE: + 2025-01-01: 2219.66 2026-01-01: 2313.67 SOUTH_TYNESIDE: + 2025-01-01: 2311.18 2026-01-01: 2434.43 SOUTHAMPTON: + 2025-01-01: 2266.44 2026-01-01: 2381.48 SOUTHEND_ON_SEA: + 2025-01-01: 2154.65 2026-01-01: 2265.17 SOUTHWARK: + 2025-01-01: 1877.90 2026-01-01: 1967.26 SPELTHORNE: + 2025-01-01: 2412.78 2026-01-01: 2526.44 ST_ALBANS: + 2025-01-01: 2305.95 2026-01-01: 2419.22 ST_HELENS: + 2025-01-01: 2288.53 2026-01-01: 2403.38 STAFFORD: + 2025-01-01: 2210.78 2026-01-01: 2302.68 STAFFORDSHIRE_MOORLANDS: + 2025-01-01: 2240.43 2026-01-01: 2338.21 STEVENAGE: + 2025-01-01: 2281.28 2026-01-01: 2391.97 STIRLING: 2025-01-01: 1611.78 + 2026-01-01: 1752.87 STOCKPORT: + 2025-01-01: 2475.31 2026-01-01: 2618.90 STOCKTON_ON_TEES: + 2025-01-01: 2468.74 2026-01-01: 2589.83 STOKE_ON_TRENT: + 2025-01-01: 2078.34 2026-01-01: 2183.12 STRATFORD_ON_AVON: + 2025-01-01: 2379.91 2026-01-01: 2484.17 STROUD: + 2025-01-01: 2369.37 2026-01-01: 2491.36 SUNDERLAND: + 2025-01-01: 2092.79 2026-01-01: 2197.14 SURREY_HEATH: + 2025-01-01: 2467.86 2026-01-01: 2585.16 SUTTON: + 2025-01-01: 2269.72 2026-01-01: 2378.64 SWALE: + 2025-01-01: 2305.72 2026-01-01: 2405.65 SWANSEA: + 2025-01-01: 2142.88 2026-01-01: 2238.29 SWINDON: + 2025-01-01: 2317.63 2026-01-01: 2438.10 TAMESIDE: + 2025-01-01: 2311.80 2026-01-01: 2447.21 TAMWORTH: + 2025-01-01: 2209.81 2026-01-01: 2300.76 TANDRIDGE: + 2025-01-01: 2471.62 2026-01-01: 2594.85 TEIGNBRIDGE: + 2025-01-01: 2512.83 2026-01-01: 2643.12 TELFORD_AND_WREKIN: + 2025-01-01: 2146.19 2026-01-01: 2256.26 TENDRING: + 2025-01-01: 2178.46 2026-01-01: 2269.63 TEST_VALLEY: + 2025-01-01: 2196.53 2026-01-01: 2305.52 TEWKESBURY: + 2025-01-01: 2226.55 2026-01-01: 2340.53 THANET: + 2025-01-01: 2391.23 2026-01-01: 2490.90 THREE_RIVERS: + 2025-01-01: 2306.47 2026-01-01: 2422.31 THURROCK: + 2025-01-01: 2145.06 2026-01-01: 2254.68 TONBRIDGE_AND_MALLING: + 2025-01-01: 2374.77 2026-01-01: 2471.83 TORBAY: + 2025-01-01: 2350.64 2026-01-01: 2470.25 TORFAEN: + 2025-01-01: 2059.50 2026-01-01: 2155.68 TORRIDGE: + 2025-01-01: 2479.12 2026-01-01: 2601.42 TOWER_HAMLETS: + 2025-01-01: 1754.57 2026-01-01: 1837.78 TRAFFORD: + 2025-01-01: 2122.54 2026-01-01: 2291.70 TUNBRIDGE_WELLS: + 2025-01-01: 2347.20 2026-01-01: 2446.15 UTTLESFORD: + 2025-01-01: 2237.45 2026-01-01: 2333.82 VALE_OF_GLAMORGAN: + 2025-01-01: 2093.96 2026-01-01: 2231.54 VALE_OF_WHITE_HORSE: + 2025-01-01: 2456.82 2026-01-01: 2577.44 WAKEFIELD: + 2025-01-01: 2185.93 2026-01-01: 2296.89 WALSALL: + 2025-01-01: 2498.27 2026-01-01: 2627.48 WALTHAM_FOREST: + 2025-01-01: 2277.65 2026-01-01: 2386.96 WANDSWORTH: + 2025-01-01: 997.75 2026-01-01: 1028.21 WARRINGTON: + 2025-01-01: 2281.40 2026-01-01: 2447.61 WARWICK: + 2025-01-01: 2356.53 2026-01-01: 2461.55 WATFORD: + 2025-01-01: 2343.38 2026-01-01: 2446.70 WAVERLEY: + 2025-01-01: 2483.85 2026-01-01: 2604.87 WEALDEN: + 2025-01-01: 2608.44 2026-01-01: 2728.43 WELWYN_HATFIELD: + 2025-01-01: 2333.46 2026-01-01: 2452.26 WEST_BERKSHIRE: + 2025-01-01: 2382.84 2026-01-01: 2505.37 WEST_DEVON: + 2025-01-01: 2574.29 2026-01-01: 2705.33 WEST_DUNBARTONSHIRE: 2025-01-01: 1559.86 + 2026-01-01: 1681.53 WEST_LANCASHIRE: + 2025-01-01: 2362.12 2026-01-01: 2456.23 WEST_LINDSEY: + 2025-01-01: 2279.77 2026-01-01: 2354.25 WEST_LOTHIAN: 2025-01-01: 1515.45 + 2026-01-01: 1627.59 +WEST_NORTHAMPTONSHIRE: + 2025-01-01: 2362.51 + 2026-01-01: 2486.66 WEST_OXFORDSHIRE: + 2025-01-01: 2443.88 2026-01-01: 2566.95 WEST_SUFFOLK: + 2025-01-01: 2243.00 2026-01-01: 2351.47 WESTMINSTER: + 2025-01-01: 1019.00 2026-01-01: 1049.55 +WESTMORLAND_AND_FURNESS: + 2025-01-01: 2389.00 + 2026-01-01: 2507.99 WIGAN: + 2025-01-01: 2031.28 2026-01-01: 2152.68 WILTSHIRE: + 2025-01-01: 2443.40 2026-01-01: 2571.78 WINCHESTER: + 2025-01-01: 2251.22 2026-01-01: 2359.76 WINDSOR_AND_MAIDENHEAD: + 2025-01-01: 1823.50 2026-01-01: 1952.62 WIRRAL: + 2025-01-01: 2381.66 2026-01-01: 2500.59 WOKING: + 2025-01-01: 2482.03 2026-01-01: 2598.04 WOKINGHAM: + 2025-01-01: 2376.11 2026-01-01: 2497.70 WOLVERHAMPTON: + 2025-01-01: 2413.98 2026-01-01: 2538.99 WORCESTER: + 2025-01-01: 2233.93 2026-01-01: 2405.81 WORTHING: + 2025-01-01: 2343.12 2026-01-01: 2456.13 WREXHAM: + 2025-01-01: 2192.89 2026-01-01: 2308.88 WYCHAVON: + 2025-01-01: 2200.98 2026-01-01: 2373.19 WYRE: + 2025-01-01: 2365.73 2026-01-01: 2460.53 WYRE_FOREST: + 2025-01-01: 2327.49 2026-01-01: 2512.47 YORK: + 2025-01-01: 2175.05 2026-01-01: 2287.15 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml index 8b8404de9e..373236e552 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml @@ -1,4 +1,4 @@ -description: Statutory council tax band proportions for Scotland, relative to Band D. Bands E to H were increased from 1 April 2017. +description: Statutory council tax band proportions for Scotland, relative to Band D. Bands A to D have been unchanged since 1993; SSI 2016/368 substituted a new proportion in section 74(1) of the Local Government Finance Act 1992, raising bands E to H with effect for financial years beginning on or after 1 April 2017. metadata: economy: false propagate_metadata_to_children: true @@ -6,23 +6,27 @@ metadata: period: year label: Scotland council tax band ratios reference: - - title: Local Government Finance Act 1992, section 5 - href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 - - title: The Council Tax (Substitution of Proportion) (Scotland) Order 2016 - href: https://www.legislation.gov.uk/ssi/2016/123/made + - title: Local Government Finance Act 1992, section 74 (proportion 6:7:8:9:11:13:15:18 as originally enacted) + href: https://www.legislation.gov.uk/ukpga/1992/14/section/74/enacted + - title: The Council Tax (Substitution of Proportion) (Scotland) Order 2016 (SSI 2016/368), article 2 (proportion 240:280:320:360:473:585:705:882 for financial years from 1 April 2017) + href: https://www.legislation.gov.uk/ssi/2016/368/article/2/made A: - 2017-04-01: 0.666666667 # 2/3 + 1993-04-01: 0.666666667 # 6/9, and 240/360 from 2017 B: - 2017-04-01: 0.777777778 # 7/9 + 1993-04-01: 0.777777778 # 7/9, and 280/360 from 2017 C: - 2017-04-01: 0.888888889 # 8/9 + 1993-04-01: 0.888888889 # 8/9, and 320/360 from 2017 D: - 2017-04-01: 1.000000000 # 1/1 + 1993-04-01: 1.000000000 # 9/9, and 360/360 from 2017 E: + 1993-04-01: 1.222222222 # 11/9 2017-04-01: 1.313888889 # 473/360 F: - 2017-04-01: 1.625000000 # 13/8 + 1993-04-01: 1.444444444 # 13/9 + 2017-04-01: 1.625000000 # 585/360 G: - 2017-04-01: 1.958333333 # 47/24 + 1993-04-01: 1.666666667 # 15/9 + 2017-04-01: 1.958333333 # 705/360 H: - 2017-04-01: 2.450000000 # 49/20 + 1993-04-01: 2.000000000 # 18/9 + 2017-04-01: 2.450000000 # 882/360 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml index 4f0fae6fe3..b20b567ee0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml @@ -215,8 +215,8 @@ local_authority: CITY_OF_EDINBURGH council_tax_band: H output: - council_tax_band_d_amount: 1563.51 - simulated_council_tax: 3830.6 + council_tax_band_d_amount: 1626.05 + simulated_council_tax: 3983.82 - name: "Band D identity: liability equals the Band D amount in Maidstone" period: 2026 @@ -237,3 +237,123 @@ output: council_tax_band_d_amount: 2502.59 simulated_council_tax: 2502.59 + +- name: "Fiscal 2025 carries its own Band D level in England (not the 2026-27 one)" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2403.78 + simulated_council_tax: 2403.78 + +- name: "Fiscal 2025 carries its own Band D level in Wales" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: D + output: + council_tax_band_d_amount: 1926.18 + simulated_council_tax: 1926.18 + +- name: "Scotland Band H at 2025 uses the 2025-26 Band D level" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1563.51 + simulated_council_tax: 3830.60 + +- name: "Scotland Band H before April 2017 used the original 18/9 proportion" + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1563.51 + simulated_council_tax: 3127.02 + +- name: "A 2023 unitary authority resolves to its own Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_WEST + local_authority: SOMERSET + council_tax_band: D + output: + council_tax_band_d_amount: 2560.59 + simulated_council_tax: 2560.59 + +- name: "Westmorland and Furness, a 2023 unitary, loads at 2025" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTH_WEST + local_authority: WESTMORLAND_AND_FURNESS + council_tax_band: D + output: + council_tax_band_d_amount: 2389.00 + simulated_council_tax: 2389.00 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py index 725ac12ca2..4e72f9f956 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py @@ -7,9 +7,13 @@ class council_tax_band_d_amount(Variable): label = "Band D council tax amount" documentation = ( "The Band D council tax amount set by the household's local authority, " - "before discounts, exemptions or Council Tax Reduction. Only one " - "year of data is loaded (2026-27 for England and Wales, 2025-26 for " - "Scotland), so earlier years are held flat backwards." + "before discounts, exemptions or Council Tax Reduction. For England " + "and Wales this is the area figure, which includes the county, police, " + "fire, combined authority and parish or community council precepts as " + "well as the billing authority's own charge; for Scotland it excludes " + "the separately billed water and waste water charges. Fiscal 2025 and " + "2026 are loaded for all three nations, so earlier years are held flat " + "backwards from 2025." ) definition_period = YEAR unit = GBP @@ -18,8 +22,8 @@ class council_tax_band_d_amount(Variable): def formula(household, period, parameters): la = household("local_authority", period).decode_to_str() amounts = parameters(period).gov.local_authorities.council_tax.band_d.amounts - # Dict lookup rather than bracket indexing: Northern Ireland (domestic - # rates, not council tax) and six post-2021 unitary authorities absent - # from the LocalAuthority enum have no Band D amount, and should return - # zero rather than raise. + # Dict lookup rather than bracket indexing: the LocalAuthority enum + # also carries Northern Ireland districts (domestic rates, not council + # tax) and historical pre-2023 English districts, neither of which has + # a Band D amount, so those should return zero rather than raise. return np.array([amounts._children.get(a, 0) for a in la], dtype=float) diff --git a/policyengine_uk/variables/household/demographic/locations.py b/policyengine_uk/variables/household/demographic/locations.py index 2b96b369e9..aa49857840 100644 --- a/policyengine_uk/variables/household/demographic/locations.py +++ b/policyengine_uk/variables/household/demographic/locations.py @@ -291,6 +291,7 @@ class LocalAuthority(Enum): CORNWALL = "Cornwall" COTSWOLD = "Cotswold" COUNTY_DURHAM = "County Durham" + CUMBERLAND = "Cumberland" COVENTRY = "Coventry" CRAVEN = "Craven" CRAWLEY = "Crawley" @@ -572,6 +573,7 @@ class LocalAuthority(Enum): WEST_DUNBARTONSHIRE = "West Dunbartonshire" WEST_LANCASHIRE = "West Lancashire" WEST_LINDSEY = "West Lindsey" + WEST_NORTHAMPTONSHIRE = "West Northamptonshire" WEST_LOTHIAN = "West Lothian" WEST_NORTHAMPTONSHIRE = "West Northamptonshire" WEST_OXFORDSHIRE = "West Oxfordshire" From 283b0ae20ee7a0d993a2c83c582c2c0931d7fa5d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 7 Oct 2026 10:34:37 +0100 Subject: [PATCH 009/141] Keep main's April 2023 unitaries in LocalAuthority; test Northern Ireland, an abolished district and the missing-authority default; document the authority context Main already added the six unitaries (52f428184), so composing this branch with it defined CUMBERLAND and WEST_NORTHAMPTONSHIRE twice and the package failed to import (TypeError: 'CUMBERLAND' already defined). Every Band D row still resolves to an enum member (350 of 350). Co-Authored-By: Claude Opus 5.5 --- .../council_tax/simulated_council_tax.yaml | 79 +++++++++++++++++++ .../council_tax/council_tax_band_d_amount.py | 14 +++- .../household/demographic/locations.py | 2 - 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml index b20b567ee0..f736d70c19 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml @@ -357,3 +357,82 @@ output: council_tax_band_d_amount: 2389.00 simulated_council_tax: 2389.00 + +- name: "North Yorkshire, a 2023 unitary, resolves to its own Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: YORKSHIRE + local_authority: NORTH_YORKSHIRE + council_tax_band: D + output: + council_tax_band_d_amount: 2544.34 + simulated_council_tax: 2544.34 + +- name: "Northern Ireland levies domestic rates, so there is no Band D amount or council tax" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTHERN_IRELAND + local_authority: BELFAST + council_tax_band: D + output: + council_tax_band_d_amount: 0 + simulated_council_tax: 0 + +- name: "A district abolished in April 2023 (Allerdale) has no Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTH_WEST + local_authority: ALLERDALE + council_tax_band: D + output: + council_tax_band_d_amount: 0 + simulated_council_tax: 0 + +- name: "With no local authority supplied, the household takes the enum default, Maidstone" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py index 4e72f9f956..fbb11f0f46 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py @@ -13,7 +13,19 @@ class council_tax_band_d_amount(Variable): "well as the billing authority's own charge; for Scotland it excludes " "the separately billed water and waste water charges. Fiscal 2025 and " "2026 are loaded for all three nations, so earlier years are held flat " - "backwards from 2025." + "backwards from 2025.\n\n" + "Authority context: the amount is looked up from the household's " + "local_authority input, which the dataset must supply. Without it the " + "household takes the enum default (Maidstone), so a dataset that does " + "not populate local_authority charges every household Maidstone's " + "level. Microcosm UK assigns an authority to every household; the " + "Enhanced FRS does not, and reweights a national sample per authority " + "instead. Northern Ireland districts (domestic rates) and English " + "districts abolished in April 2023 have no Band D amount and return " + "zero. Downstream calibration should not consume this variable as a " + "national total until the dataset supplies local_authority; " + "policyengine-uk-data's Band D level target needs " + "PolicyEngine/policyengine-uk-data#484 (issue #483) first." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/household/demographic/locations.py b/policyengine_uk/variables/household/demographic/locations.py index aa49857840..2b96b369e9 100644 --- a/policyengine_uk/variables/household/demographic/locations.py +++ b/policyengine_uk/variables/household/demographic/locations.py @@ -291,7 +291,6 @@ class LocalAuthority(Enum): CORNWALL = "Cornwall" COTSWOLD = "Cotswold" COUNTY_DURHAM = "County Durham" - CUMBERLAND = "Cumberland" COVENTRY = "Coventry" CRAVEN = "Craven" CRAWLEY = "Crawley" @@ -573,7 +572,6 @@ class LocalAuthority(Enum): WEST_DUNBARTONSHIRE = "West Dunbartonshire" WEST_LANCASHIRE = "West Lancashire" WEST_LINDSEY = "West Lindsey" - WEST_NORTHAMPTONSHIRE = "West Northamptonshire" WEST_LOTHIAN = "West Lothian" WEST_NORTHAMPTONSHIRE = "West Northamptonshire" WEST_OXFORDSHIRE = "West Oxfordshire" From 50c5171641465e427e46776c7c479ec4b0662d37 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 6 May 2026 11:45:28 +0100 Subject: [PATCH 010/141] Add PIP points-based assessment scaffold (#1069) Encode the Schedule 1 Part 2/3 points thresholds as parameters and add `pip_dl_points` / `pip_m_points` person-level inputs. The existing `pip_dl_category` and `pip_m_category` enums now have formulas that derive the award category from points; data-set-supplied categories continue to win because OpenFisca treats inputs as authoritative over formulas. This is the first slice toward the full points system. Per-activity descriptor inputs and an FRS imputation step are intentionally left for follow-up PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- changelog.d/1069.md | 1 + .../points_thresholds/enhanced.yaml | 9 +++ .../points_thresholds/standard.yaml | 11 +++ .../mobility/points_thresholds/enhanced.yaml | 9 +++ .../mobility/points_thresholds/standard.yaml | 9 +++ .../policy/baseline/gov/dwp/pip_points.yaml | 73 +++++++++++++++++++ .../variables/input/pip_dl_category.py | 18 ++++- .../variables/input/pip_dl_points.py | 10 +++ .../variables/input/pip_m_category.py | 18 ++++- .../variables/input/pip_m_points.py | 10 +++ 10 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1069.md create mode 100644 policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml create mode 100644 policyengine_uk/variables/input/pip_dl_points.py create mode 100644 policyengine_uk/variables/input/pip_m_points.py diff --git a/changelog.d/1069.md b/changelog.d/1069.md new file mode 100644 index 0000000000..8f6a7c324d --- /dev/null +++ b/changelog.d/1069.md @@ -0,0 +1 @@ +- Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points when the category is not provided as input. diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml new file mode 100644 index 0000000000..a8d19f63b8 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 2 daily living activities qualifies for the enhanced rate of the PIP daily living component. +values: + 2013-04-08: 12 +metadata: + unit: integer + label: PIP daily living enhanced rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(2)(b) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml new file mode 100644 index 0000000000..48b2350927 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml @@ -0,0 +1,11 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 2 daily living activities qualifies for the standard rate of the PIP daily living component. +values: + 2013-04-08: 8 +metadata: + unit: integer + label: PIP daily living standard rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(2)(a) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 + - title: PIP assessment guide for assessment providers — Part 2 (Daily Living) + href: https://www.gov.uk/government/publications/personal-independence-payment-assessment-guide-for-assessment-providers diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml new file mode 100644 index 0000000000..44562a518e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 3 mobility activities qualifies for the enhanced rate of the PIP mobility component. +values: + 2013-04-08: 12 +metadata: + unit: integer + label: PIP mobility enhanced rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(2)(b) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml new file mode 100644 index 0000000000..d8c1274fcf --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 3 mobility activities qualifies for the standard rate of the PIP mobility component. +values: + 2013-04-08: 8 +metadata: + unit: integer + label: PIP mobility standard rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(2)(a) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml new file mode 100644 index 0000000000..777e0cd479 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml @@ -0,0 +1,73 @@ +- name: PIP daily living — zero points yields no award + period: 2025 + input: + pip_dl_points: 0 + pip_m_points: 0 + output: + pip_dl_category: NONE + pip_m_category: NONE + pip_dl: 0 + pip_m: 0 + +- name: PIP daily living — 7 points is below the standard threshold + period: 2025 + input: + pip_dl_points: 7 + output: + pip_dl_category: NONE + pip_dl: 0 + +- name: PIP daily living — 8 points hits the standard threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 8 + output: + pip_dl_category: STANDARD + pip_dl: 3842.28 + +- name: PIP daily living — 11 points stays at standard + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 11 + output: + pip_dl_category: STANDARD + pip_dl: 3842.28 + +- name: PIP daily living — 12 points hits the enhanced threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 12 + output: + pip_dl_category: ENHANCED + pip_dl: 5740.80 + +- name: PIP mobility — 8 points hits the standard threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_m_points: 8 + output: + pip_m_category: STANDARD + pip_m: 1517.88 + +- name: PIP mobility — 12 points hits the enhanced threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_m_points: 12 + output: + pip_m_category: ENHANCED + pip_m: 4006.08 + +- name: PIP — input category overrides points-derived category + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 0 + pip_dl_category: ENHANCED + output: + pip_dl_category: ENHANCED + pip_dl: 5740.80 diff --git a/policyengine_uk/variables/input/pip_dl_category.py b/policyengine_uk/variables/input/pip_dl_category.py index 45a933e786..7b74298a8a 100644 --- a/policyengine_uk/variables/input/pip_dl_category.py +++ b/policyengine_uk/variables/input/pip_dl_category.py @@ -4,9 +4,25 @@ class pip_dl_category(Variable): label = "PIP (daily living) category" - documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline." + documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input, the category is derived from `pip_dl_points` against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`." entity = Person definition_period = YEAR value_type = Enum possible_values = PIPCategory default_value = PIPCategory.NONE + + def formula(person, period, parameters): + points = person("pip_dl_points", period) + thresholds = parameters(period).gov.dwp.pip.daily_living.points_thresholds + return select( + [ + points >= thresholds.enhanced, + points >= thresholds.standard, + points < thresholds.standard, + ], + [ + PIPCategory.ENHANCED, + PIPCategory.STANDARD, + PIPCategory.NONE, + ], + ) diff --git a/policyengine_uk/variables/input/pip_dl_points.py b/policyengine_uk/variables/input/pip_dl_points.py new file mode 100644 index 0000000000..d05830015d --- /dev/null +++ b/policyengine_uk/variables/input/pip_dl_points.py @@ -0,0 +1,10 @@ +from policyengine_uk.model_api import * + + +class pip_dl_points(Variable): + label = "PIP daily living points" + documentation = "Total descriptor points scored across the ten Schedule 1 Part 2 daily living activities. Used to derive `pip_dl_category` when the category is not provided as input." + entity = Person + definition_period = YEAR + value_type = int + default_value = 0 diff --git a/policyengine_uk/variables/input/pip_m_category.py b/policyengine_uk/variables/input/pip_m_category.py index e523d95ef9..ae3749601a 100644 --- a/policyengine_uk/variables/input/pip_m_category.py +++ b/policyengine_uk/variables/input/pip_m_category.py @@ -4,9 +4,25 @@ class pip_m_category(Variable): label = "PIP (mobility) category" - documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline." + documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input, the category is derived from `pip_m_points` against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`." entity = Person definition_period = YEAR value_type = Enum possible_values = PIPCategory default_value = PIPCategory.NONE + + def formula(person, period, parameters): + points = person("pip_m_points", period) + thresholds = parameters(period).gov.dwp.pip.mobility.points_thresholds + return select( + [ + points >= thresholds.enhanced, + points >= thresholds.standard, + points < thresholds.standard, + ], + [ + PIPCategory.ENHANCED, + PIPCategory.STANDARD, + PIPCategory.NONE, + ], + ) diff --git a/policyengine_uk/variables/input/pip_m_points.py b/policyengine_uk/variables/input/pip_m_points.py new file mode 100644 index 0000000000..022670718b --- /dev/null +++ b/policyengine_uk/variables/input/pip_m_points.py @@ -0,0 +1,10 @@ +from policyengine_uk.model_api import * + + +class pip_m_points(Variable): + label = "PIP mobility points" + documentation = "Total descriptor points scored across the two Schedule 1 Part 3 mobility activities. Used to derive `pip_m_category` when the category is not provided as input." + entity = Person + definition_period = YEAR + value_type = int + default_value = 0 From 24c30238418c90e3dbe67253d30b18f321ee0748 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 15:48:14 +0100 Subject: [PATCH 011/141] Type the changelog fragment as Added Co-Authored-By: Claude Opus 5.5 --- changelog.d/1069.added.md | 1 + changelog.d/1069.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/1069.added.md delete mode 100644 changelog.d/1069.md diff --git a/changelog.d/1069.added.md b/changelog.d/1069.added.md new file mode 100644 index 0000000000..173fa54df2 --- /dev/null +++ b/changelog.d/1069.added.md @@ -0,0 +1 @@ +Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points when the category is not provided as input. diff --git a/changelog.d/1069.md b/changelog.d/1069.md deleted file mode 100644 index 8f6a7c324d..0000000000 --- a/changelog.d/1069.md +++ /dev/null @@ -1 +0,0 @@ -- Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points when the category is not provided as input. From 8b18c4753a0fb00c4972c070a298f317ebfa6bfa Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 9 Oct 2026 18:02:58 +0100 Subject: [PATCH 012/141] Rename changelog fragment to council-tax-computed-liability.added.md (review: typed fragment) Co-Authored-By: Claude Opus 5.5 --- ...puted-liability.md => council-tax-computed-liability.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{council-tax-computed-liability.md => council-tax-computed-liability.added.md} (100%) diff --git a/changelog.d/council-tax-computed-liability.md b/changelog.d/council-tax-computed-liability.added.md similarity index 100% rename from changelog.d/council-tax-computed-liability.md rename to changelog.d/council-tax-computed-liability.added.md From bcdee62b32a205c910e55276c6e0d2e082cfd74d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:41:13 -0400 Subject: [PATCH 013/141] Uprate additional State Pension and protected payments by September CPI The part of a reported State Pension above the type's full rate (additional pension for the basic State Pension, protected payments for the new) was uprated by the full rate's own triple-lock rise. Each Up-rating Order raises it by September CPI instead (SSAA 1992 ss.150(1)(c), 151 and 151A; SI 2026/148 arts 4(3) and 6(3)(b): 3.8% against 4.8% for the full rates; SI 2025/295: 1.7% against 4.1%). Uprate it by gov.economic_assumptions.indices.september_cpi_uprating from the data year, read from the simulation's own parameters, so a reform to the full rates or the triple lock no longer moves it and a price scenario does. Fixes #1941. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1941.fixed.md | 1 + docs/book/programs/gov/dwp/state-pension.md | 62 ++- .../tests/test_state_pension_components.py | 451 ++++++++++++++++-- .../gov/dwp/additional_state_pension.py | 44 +- 4 files changed, 484 insertions(+), 74 deletions(-) create mode 100644 changelog.d/1941.fixed.md diff --git a/changelog.d/1941.fixed.md b/changelog.d/1941.fixed.md new file mode 100644 index 0000000000..51bd32d483 --- /dev/null +++ b/changelog.d/1941.fixed.md @@ -0,0 +1 @@ +- Uprate additional State Pension and protected payments by September CPI, as the Up-rating Orders do (SI 2026/148 arts 4(3) and 6(3)(b)), not by the full rate's triple-lock rise; reforms to the full rates no longer move them. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 985576b98a..e4aafcff7c 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -26,20 +26,41 @@ Three variables under `gov/dwp/`: current law but tracked separately in PolicyEngine so the reform surface stays clean. - Both are the reported weekly amount above the type's full rate in the - data year, `max(reported - flat_max_for_type, 0)`, uprated as described - below and multiplied by `WEEKS_IN_YEAR`. + data year, `max(reported - flat_max_for_type, 0)`, uprated by September + CPI as described below and multiplied by `WEEKS_IN_YEAR`. All three split the amount a person reported in the data year (the dataset's first year) by the person's `state_pension_type` in the year -simulated. The part up to that type's full rate in the data year is basic -or new State Pension, and the part above it is additional State Pension. -Both parts are uprated by the type's full rate (the year's over the data -year's), so together they pay the reported amount uprated by the flat rate, -for anyone over State Pension age. In law, additional pensions and -protected payments rise with prices instead: the Social Security Benefits -Up-rating Order 2026 raised them by 3.8% in April 2026 (articles 4(3) and -6(3)), and the full rates by 4.8%. This is tracked in -[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941). +simulated, for anyone over State Pension age: + +- The part up to that type's full rate in the data year is basic or new + State Pension. It is uprated by the type's full rate: the year's over the + data year's. +- The part above it is additional State Pension. It is uprated by September + CPI: `gov.economic_assumptions.indices.september_cpi_uprating` in the year + over the data year. + +That follows the law. Each year's Up-rating Order raises the full rates by +the triple lock (Social Security Administration Act 1992 s.150A), but raises +additional pensions (s.150(1)(c)), increments for deferring the pension and +the amounts of new State Pension above the full rate (protected payments, +s.151A) by the September CPI of the year before. In April 2026 the full +rates rose 4.8% and the rest 3.8% +([Social Security Benefits Up-rating Order 2026](https://www.legislation.gov.uk/uksi/2026/148/made), +articles 4(2), 4(3), 6(1) and 6(3)); in April 2025, 4.1% and 1.7% +([2025 Order](https://www.legislation.gov.uk/uksi/2025/295/made), the same +articles). Graduated retirement benefit, paid with the pension, also rose by +September CPI (article 11 of each Order). Until the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941) the +model uprated the part above the full rate by the full rate too. + +So a reform to the full rates, or to the triple lock, moves only basic and +new State Pension. A reform that sets a full rate to zero leaves additional +pensions and protected payments in place; `gov.contrib.abolish_state_pension` +removes all State Pension. The September CPI index is read from the +simulation's own parameters, so a scenario applied before the data load that +changes September CPI, or the calendar-year CPI growth its forecast follows, +moves the part above the full rate. Survey ages are held fixed in the years a dataset is projected to, so a record's birth cohort moves one year later for each year projected, and its @@ -167,6 +188,19 @@ to 2026-27; later years are uprated by `gov.economic_assumptions.indices.triple_lock`, built from the yearly rates in `gov.economic_assumptions.yoy_growth.triple_lock`. +The part of a reported State Pension above the full rate is uprated by +`gov.economic_assumptions.indices.september_cpi_uprating` instead (see +Components). Its yearly rises, +`gov.economic_assumptions.yoy_growth.september_cpi_uprating`, are the +September CPI of the year before from `statutory_uprating_inputs.cpi_september` +(below), rounded to the 0.1 percentage points the ONS publishes, and zero +when prices fall (`create_september_cpi_uprating.py`). They equal the rise +each Up-rating Order gave additional pensions from April 2011 to April 2026, +including none in April 2016, when September 2015 CPI was -0.1% +(`policyengine_uk/tests/test_state_pension_components.py`). The index is +rounded to five decimal places each year, so it differs from the exact +product of the rises by less than 0.001%. + ### The triple lock The rise each April is the highest of: @@ -360,8 +394,9 @@ tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): population keeps the data year's age structure. From 2025-26 to 2030-31 the model's State Pension recipients fall 3.0%, as State Pension age rises to 67, while DWP's caseload less those paid abroad rises 4.8%. -- **Awards.** Each record keeps its reported amount, uprated by the flat - rate, including records whose cohort moves from basic to new State +- **Awards.** Each record keeps its reported amount, the part up to the + full rate uprated by the flat rate and the part above it by September CPI, + including records whose cohort moves from basic to new State Pension. The model's State Pension per recipient grows 17.0% over those years. DWP's spending per recipient, less those paid abroad, grows 18.3%, as its new State Pension spending grows from £56.1bn to £102.4bn and its @@ -382,6 +417,7 @@ part of the widening. | `new_state_pension` returned flat max for every NEW retiree, ignoring partial NI records | Fixed | #1634 | | Protected Payment only computed for BASIC-type recipients | Fixed | #1634 (ASP now extends to NEW) | | `additional_state_pension` split the reported amount by the data year's State Pension type, paying part of it twice for records moving from basic to new cohorts | Fixed | #1921 | +| `additional_state_pension` uprated additional pensions and protected payments by the full rate (the triple lock), not September CPI | Fixed | #1941 | ### What's still open diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 439e77a639..8487b260aa 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -7,12 +7,15 @@ period can differ from its type in the data year. All three components split by the period's type, so together they split the reported amount with no overlap and no gap: the part up to the type's full rate is uprated by the full -rate, and the part above it by the add-on's uprating (see add_on_uprating). +rate, and the part above it (additional pension, protected payments) by +September CPI, as the Up-rating Orders raise it (see add_on_uprating). The YAML runner builds simulations without a dataset, where the data year is the period by construction, so these cases build simulations from data. """ +from decimal import ROUND_HALF_UP, Decimal + import numpy as np import pandas as pd import pytest @@ -21,6 +24,8 @@ from policyengine_uk import Microsimulation from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.model_api import Scenario +from policyengine_uk.system import system DATA_YEAR = 2024 YEARS = range(DATA_YEAR, 2031) @@ -84,24 +89,82 @@ def flat_uprating(sim, year: int, pension_type: str) -> float: ) -def add_on_uprating(sim, year: int, pension_type: str) -> float: +# The rise each Social Security Benefits Up-rating Order gave additional +# pensions in long-term benefits, in April of the year: September CPI of the +# year before, under the Social Security Administration Act 1992 ss.150(1)(c) +# and 151(1). Since 2017 the same Orders raise the amounts of new State +# Pension at the transitional rate above the full rate (protected payments) by +# the same percentage, under s.151A. The 2016 Order (SI 2016/230) raised no +# additional pension: September 2015 CPI was -0.1%. For example, SI 2026/148 +# art 4(3)(a) and 6(3)(b) (3.8%) and SI 2025/295 art 4(3)(a) and 6(3)(b) +# (1.7%), against 4.8% and 4.1% for the full rates (arts 4(2) and 6(1)). +UP_RATING_ORDERS = { + 2011: ("SI 2011/821", 0.031), + 2012: ("SI 2012/780", 0.052), + 2013: ("SI 2013/574", 0.022), + 2014: ("SI 2014/516", 0.027), + 2015: ("SI 2015/457", 0.012), + 2016: ("SI 2016/230", 0.0), + 2017: ("SI 2017/260", 0.010), + 2018: ("SI 2018/281", 0.030), + 2019: ("SI 2019/480", 0.024), + 2020: ("SI 2020/234", 0.017), + 2021: ("SI 2021/162", 0.005), + 2022: ("SI 2022/292", 0.031), + 2023: ("SI 2023/316", 0.101), + 2024: ("SI 2024/242", 0.067), + 2025: ("SI 2025/295", 0.017), + 2026: ("SI 2026/148", 0.038), +} +# The model compounds the yearly rises into an index rounded to five decimal +# places each year (create_economic_assumption_indices.py), so it differs from +# the exact product of the rises by under 1e-5 of the amount. +INDEX_ROUNDING = 1e-5 + + +def add_on_rise(sim, year: int) -> float: + """The April rise in additional pensions and protected payments: the + Up-rating Order's where one has been made. For later years, the forecast + September CPI of the year before, as ONS publishes it (to 0.1 percentage + points), and no rise if prices fall (an Order under SSAA 1992 s.150(2) or + s.151A(2) is made only if prices rose).""" + if year in UP_RATING_ORDERS: + return UP_RATING_ORDERS[year][1] + parameters = sim.tax_benefit_system.parameters + inputs = parameters.gov.economic_assumptions.statutory_uprating_inputs + september = inputs.cpi_september(f"{year - 1}-09-01") + published = float( + Decimal(repr(float(september))).quantize(Decimal("0.001"), ROUND_HALF_UP) + ) + return max(published, 0.0) + + +def add_on_uprating(sim, year: int) -> float: """Uprating of the part above the full rate (additional pension, protected - payments). The model uprates it by the full rate; in law these rise with - prices (Social Security Benefits Up-rating Order 2026 arts 4(3) and 6(3)), - tracked in PolicyEngine/policyengine-uk#1941. Change it here with the model.""" - return flat_uprating(sim, year, pension_type) + payments) from the data year to ``year``: the yearly rises compounded. A + reference independent of the model's index, which follows the same rises + whatever the State Pension type (PolicyEngine/policyengine-uk#1941).""" + factor = 1.0 + for later_year in range(DATA_YEAR + 1, year + 1): + factor *= 1 + add_on_rise(sim, later_year) + return factor def uprated_reported(sim, year: int, pension_type: str, weekly: float) -> float: """The data-year amount, its part up to the full rate uprated by the full - rate and the part above it by the add-on's uprating.""" + rate and the part above it by September CPI.""" full = flat_rates(sim, DATA_YEAR)[pension_type] return WEEKS_IN_YEAR * ( min(weekly, full) * flat_uprating(sim, year, pension_type) - + max(weekly - full, 0) * add_on_uprating(sim, year, pension_type) + + max(weekly - full, 0) * add_on_uprating(sim, year) ) +def approx(expected: float): + """Exact to the penny, allowing for the model index's rounding.""" + return pytest.approx(expected, rel=INDEX_ROUNDING, abs=0.01) + + def calculate(sim, variable: str, year: int) -> np.ndarray: return np.asarray(sim.calculate(variable, year)) @@ -134,7 +197,7 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): 2014 on the basic State Pension; held at 72 she stands, by 2030-31, for a woman born in 1958, on the new State Pension. Her £240 a week exceeds the new State Pension's full rate, so the excess is additional State Pension - (a protected payment), uprated as the model uprates add-ons.""" + (a protected payment), uprated by September CPI.""" person = {"age": 72, "male": False, "months": 3, "weekly": 240} sim = projected_simulation([person]) assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" @@ -143,9 +206,8 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): assert calculate(sim, "new_state_pension", 2030)[0] == pytest.approx( rates["NEW"] * WEEKS_IN_YEAR, abs=0.01 ) - assert calculate(sim, "additional_state_pension", 2030)[0] == pytest.approx( - (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030, "NEW"), - abs=0.01, + assert calculate(sim, "additional_state_pension", 2030)[0] == approx( + (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030) ) @@ -164,8 +226,8 @@ def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): for year in YEARS: assert calculate(sim, "state_pension_type", year)[0] == pension_type total = sum(calculate(sim, v, year)[0] for v in COMPONENTS) - assert total == pytest.approx( - uprated_reported(sim, year, pension_type, person["weekly"]), abs=0.01 + assert total == approx( + uprated_reported(sim, year, pension_type, person["weekly"]) ) @@ -200,10 +262,170 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): assert list(calculate(sim, "state_pension_type", DATA_YEAR)) == ["NEW", "NONE"] assert list(calculate(sim, "state_pension_type", 2030)) == ["BASIC", "NEW"] total = sum(calculate(sim, v, 2030) for v in COMPONENTS) - assert total[0] == pytest.approx( - uprated_reported(sim, 2030, "BASIC", 240), abs=0.01 + assert total[0] == approx(uprated_reported(sim, 2030, "BASIC", 240)) + assert total[1] == approx(uprated_reported(sim, 2030, "NEW", 230)) + + +# The add-on rises with prices (PolicyEngine/policyengine-uk#1941). Records are +# built from 2024-25 data, where the full rates are £169.50 (basic State +# Pension, Social Security Benefits Up-rating Order 2024 art 4(2)) and £221.20 +# (new State Pension, art 6(1)). + + +def test_additional_pension_rises_with_september_cpi_not_the_basic_rate(): + """A man aged 90 in 2024-25 reached State Pension age in 1999, on the basic + State Pension, as does a man aged 90 in any later year. He reports £200 a + week, £30.50 above the 2024-25 full basic rate. The Up-rating Orders raised + the basic State Pension by 4.1% and 4.8% and additional pensions by 1.7% + and 3.8% (SI 2025/295 and SI 2026/148, arts 4(2) and 4(3)(a)), so in + 2026-27 he gets £184.90 a week of basic State Pension and £30.50 x 1.017 x + 1.038 of additional State Pension.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 200} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "BASIC" + additional_2025 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 + additional_2026 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", DATA_YEAR)[0] == approx( + (200 - 169.50) * WEEKS_IN_YEAR + ) + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + additional_2025 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx( + additional_2026 + ) + assert calculate(sim, "basic_state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + additional_2026 + ) + + +def test_protected_payment_rises_with_september_cpi_not_the_new_rate(): + """A man aged 70 in 2024-25 reached State Pension age in 2020, on the new + State Pension, as does a man aged 70 in 2026-27. He reports £240 a week, + £18.80 above the 2024-25 full rate: a protected payment. The Up-rating + Orders raised the full rate by 4.1% and 4.8% and protected payments by + 1.7% and 3.8% (SI 2025/295 and SI 2026/148, arts 6(1) and 6(3)(b)), so in + 2026-27 he gets £241.30 a week of new State Pension and £18.80 x 1.017 x + 1.038 of protected payment.""" + person = {"age": 70, "male": True, "months": 3, "weekly": 240} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "NEW" + protected_2026 = (240 - 221.20) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + (240 - 221.20) * WEEKS_IN_YEAR * 1.017 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx(protected_2026) + assert calculate(sim, "new_state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + protected_2026 + ) + + +@pytest.mark.parametrize( + "person", + [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ], + ids=["additional_pension", "protected_payment"], +) +def test_add_on_grows_each_year_by_that_years_september_cpi_rise(person): + """Each April the add-on rises by the September CPI of the year before: + the Orders' 1.7% and 3.8%, then the forecast.""" + sim = projected_simulation([person]) + for year in YEARS[1:]: + growth = ( + calculate(sim, "additional_state_pension", year)[0] + / calculate(sim, "additional_state_pension", year - 1)[0] + ) + assert growth == pytest.approx(1 + add_on_rise(sim, year), abs=INDEX_ROUNDING) + + +def test_september_cpi_uprating_matches_the_up_rating_orders(): + """The index the add-on follows rises each April by the percentage the + Up-rating Order gave additional pensions that year, 0% in April 2016.""" + parameters = system.parameters + indices = parameters.gov.economic_assumptions.indices + yoy_growth = parameters.gov.economic_assumptions.yoy_growth + for year, (order, rise) in UP_RATING_ORDERS.items(): + assert yoy_growth.september_cpi_uprating(year) == pytest.approx(rise), order + growth = indices.september_cpi_uprating(year) / indices.september_cpi_uprating( + year - 1 + ) + assert growth == pytest.approx(1 + rise, abs=INDEX_ROUNDING), order + + +@pytest.mark.parametrize( + "scenario", + [ + # Full rates set by reform from 2026-27: the add-on is not scaled with + # them. + Scenario( + parameter_changes={ + "gov.dwp.state_pension.basic_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + "gov.dwp.state_pension.new_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + } + ), + # A 10% triple-lock floor from April 2027 raises the full rates, not + # the add-on. + Scenario( + parameter_changes={ + "gov.dwp.state_pension.triple_lock.minimum_rate": { + "year:2027-01-01:100": 0.1 + }, + }, + applied_before_data_load=True, + ), + ], + ids=["full_rates", "triple_lock_floor"], +) +def test_full_rate_reforms_leave_the_add_on_alone(scenario): + people = [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ] + baseline = projected_simulation(people) + reformed = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), scenario=scenario + ) + for year in (2027, 2028, 2030): + assert calculate(reformed, "state_pension", year).sum() != pytest.approx( + calculate(baseline, "state_pension", year).sum() + ) + assert calculate(reformed, "additional_state_pension", year) == pytest.approx( + calculate(baseline, "additional_state_pension", year), abs=0.01 + ) + + +def test_a_september_cpi_scenario_moves_the_add_on(): + """The index is read from the simulation's own parameters: with September + 2027 CPI set to 5%, the add-on rises 5% in April 2028.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 250} + scenario = Scenario( + parameter_changes={ + "gov.economic_assumptions.statutory_uprating_inputs.cpi_september": { + "2027": 0.05 + }, + }, + applied_before_data_load=True, ) - assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) + sim = Microsimulation(dataset=year_dataset([person], DATA_YEAR), scenario=scenario) + growth = ( + calculate(sim, "additional_state_pension", 2028)[0] + / calculate(sim, "additional_state_pension", 2027)[0] + ) + assert growth == pytest.approx(1.05, abs=INDEX_ROUNDING) # Each example builds a microsimulation, so examples are slow to generate. @@ -255,11 +477,7 @@ def assert_components_add_up(sim, people: list, years) -> None: flat_uprating(sim, year, "BASIC"), flat_uprating(sim, year, "NEW"), ) - add_up = by_type( - pension_type, - add_on_uprating(sim, year, "BASIC"), - add_on_uprating(sim, year, "NEW"), - ) + add_up = add_on_uprating(sim, year) reported = weekly * WEEKS_IN_YEAR assert np.array_equal(pension_type == "NONE", ~over_spa), year flat = basic + new @@ -267,8 +485,7 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.allclose(flat, expected_flat, rtol=1e-5, atol=0.01) deflated = np.where( over_spa, - flat / np.where(over_spa, flat_up, 1) - + additional / np.where(over_spa, add_up, 1), + flat / np.where(over_spa, flat_up, 1) + additional / add_up, 0, ) assert np.allclose(deflated, over_spa * reported, rtol=1e-5, atol=0.01) @@ -314,10 +531,132 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data assert_components_add_up(sim, people, (DATA_YEAR, period)) +def main_components(sim, weekly: np.ndarray, year: int) -> tuple: + """basic, new and additional State Pension as policyengine-uk main computed + them before #1941 (6d89338a), with the add-on uprated by the type's full + rate. A reference for the differential test.""" + pension_type = calculate(sim, "state_pension_type", year).astype(str) + over_spa = calculate(sim, "is_SP_age", year).astype(bool) + rates, data_rates = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) + full = by_type(pension_type, data_rates["BASIC"], data_rates["NEW"]) + full_now = by_type(pension_type, rates["BASIC"], rates["NEW"]) + has_rate = full > 0 + safe_full = np.where(has_rate, full, 1) + share = np.where(has_rate, np.minimum(weekly, full) / safe_full, 0) + basic = np.where(pension_type == "BASIC", share * rates["BASIC"], 0) + new = np.where(pension_type == "NEW", share * rates["NEW"], 0) + above = np.where(pension_type != "NONE", np.maximum(weekly - full, 0), 0) + additional = over_spa * above * np.where(has_rate, full_now / safe_full, 1) + return tuple(WEEKS_IN_YEAR * x for x in (basic, new, additional)) + + +def paired(people: list, extra: list) -> list: + """Each person, then the same person reporting ``extra`` more a week.""" + return people + [ + {**person, "weekly": person["weekly"] + more} + for person, more in zip(people, extra) + ] + + +@PROPERTY_SETTINGS +@given(people=PEOPLE, data=st.data()) +def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): + """For every record and year from 2024-25 to 2030-31: + + - the add-on is never negative, and never falls as the reported amount rises; + - from one year to the next, with the type unchanged, it grows by that + year's September CPI rise; + - differential against main: the flat-rate parts are unchanged, the add-on + is unchanged in the data year, and otherwise differs only by uprating with + September CPI instead of the full rate.""" + extra = data.draw( + st.lists( + st.floats(min_value=0, max_value=300), + min_size=len(people), + max_size=len(people), + ) + ) + records = paired(people, extra) + sim = projected_simulation(records) + weekly = np.array([p["weekly"] for p in records]) + n = len(people) + previous = None + for year in YEARS: + pension_type = calculate(sim, "state_pension_type", year).astype(str) + basic, new, additional = (calculate(sim, v, year) for v in COMPONENTS) + assert np.all(additional >= 0) + assert np.all(additional[n:] >= additional[:n] - 1e-9) + main_basic, main_new, main_additional = main_components(sim, weekly, year) + assert np.allclose(basic, main_basic, rtol=1e-12, atol=1e-9) + assert np.allclose(new, main_new, rtol=1e-12, atol=1e-9) + if year == DATA_YEAR: + assert np.allclose(additional, main_additional, rtol=1e-12, atol=1e-9) + flat_up = by_type( + pension_type, + flat_uprating(sim, year, "BASIC"), + flat_uprating(sim, year, "NEW"), + ) + flat_up = np.where(pension_type == "NONE", 1, flat_up) + assert np.allclose( + additional * flat_up, + main_additional * add_on_uprating(sim, year), + rtol=INDEX_ROUNDING, + atol=0.01, + ) + if previous is not None: + previous_type, previous_additional = previous + same = (pension_type == previous_type) & (previous_additional > 0) + rise = 1 + add_on_rise(sim, year) + assert np.allclose( + additional[same], + previous_additional[same] * rise, + rtol=INDEX_ROUNDING, + atol=0, + ) + previous = (pension_type, additional) + + +RATE = st.floats(min_value=0, max_value=500) + + +@PROPERTY_SETTINGS +@given( + people=PEOPLE, + basic_rate=RATE, + new_rate=RATE, + from_year=st.sampled_from([None, 2025, 2027]), +) +def test_full_rate_reforms_never_move_the_add_on( + people, basic_rate, new_rate, from_year +): + """Differential: the same records with and without a reform to the full + rates, undated (so also in the data year) or from a later year. In law the + add-on rises with prices whatever the full rates do, and the reported amount + is still split at the legislated data-year rates, so the add-on is the same + in every year.""" + period = ( + "2000-01-01.2100-12-31" + if from_year is None + else f"{from_year}-01-01.2100-12-31" + ) + reform = {BSP: {period: basic_rate}, NSP: {period: new_rate}} + baseline = projected_simulation(people) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), reform=reform) + for year in YEARS: + assert np.allclose( + calculate(reformed, "additional_state_pension", year), + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + + # Rate reforms (PolicyEngine/policyengine-uk#2122). The reported amount is # split at the legislated (baseline) data-year rate; the flat-rate part then -# follows the reformed period rate, and the part above the legislated rate is -# scaled by the reformed period rate over the legislated data-year rate. +# follows the reformed period rate. The part above the legislated rate rises +# with September CPI, whatever the full rates (#1941): a reform to the full +# rates no longer scales it. To abolish State Pension altogether, use +# gov.contrib.abolish_state_pension. NSP = "gov.dwp.state_pension.new_state_pension.amount" BSP = "gov.dwp.state_pension.basic_state_pension.amount" @@ -331,12 +670,16 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data } -def expected_under_rate(weekly: float, legislated: float, reformed: float) -> dict: +def expected_under_rate( + weekly: float, legislated: float, reformed: float, add_on_up: float = 1 +) -> dict: """Annual flat-rate and add-on amounts for a record reporting ``weekly`` in a data year whose legislated full rate is ``legislated``, in a period whose - reformed full rate is ``reformed``.""" + reformed full rate is ``reformed`` and whose September CPI is ``add_on_up`` + times the data year's (1 without a dataset, where the data year is the + period).""" share = min(weekly, legislated) / legislated - add_on = max(weekly - legislated, 0) * reformed / legislated + add_on = max(weekly - legislated, 0) * add_on_up return { "flat": share * reformed * WEEKS_IN_YEAR, "additional": add_on * WEEKS_IN_YEAR, @@ -398,8 +741,8 @@ def test_situation_basic_state_pension_follows_a_dated_rate_reform(): def test_situation_protected_payment_stays_additional_under_a_rate_reform(): """£10 a week above the legislated full rate is a protected payment. The - flat-rate part follows the reform; the protected payment is scaled by the - reformed over the legislated rate, as add-ons are (see #1941).""" + flat-rate part follows the reform; the protected payment stays £10 a week, + as it rises with prices, not with the full rate (SI 2026/148 art 6(3)(b)).""" year = 2026 legislated = LEGISLATED[(year, "NEW")] weekly = legislated + 10 @@ -424,19 +767,26 @@ def test_situation_protected_payment_stays_additional_under_a_rate_reform(): ({"age": 90, "male": True, "months": 3, "weekly": 250}, "BASIC", BSP), ], ) -def test_undated_rate_reform_moves_state_pension_with_the_rate( +def test_undated_rate_reform_moves_only_the_flat_rate_part( person, pension_type, parameter, reformed ): """An undated reform rewrites the data year's rate too. The record is still - split at the legislated 2024-25 rate, so State Pension rises with a higher - rate, falls with a lower one and is nil at a rate of zero.""" + split at the legislated 2024-25 rate, so the flat-rate part rises with a + higher rate, falls with a lower one and is nil at a rate of zero, while the + add-on keeps rising with September CPI: at a rate of zero the record keeps + its additional pension or protected payment. This is a deliberate change + from before #1941, when the add-on was scaled with the full rate and a rate + of zero removed all State Pension.""" sim = Microsimulation( dataset=year_dataset([person], DATA_YEAR), reform={parameter: reformed} ) year = 2027 assert calculate(sim, "state_pension_type", year)[0] == pension_type expected = expected_under_rate( - person["weekly"], LEGISLATED[(DATA_YEAR, pension_type)], reformed + person["weekly"], + LEGISLATED[(DATA_YEAR, pension_type)], + reformed, + add_on_uprating(sim, year), ) flat_variable = ( "new_state_pension" if pension_type == "NEW" else "basic_state_pension" @@ -444,12 +794,27 @@ def test_undated_rate_reform_moves_state_pension_with_the_rate( assert calculate(sim, flat_variable, year)[0] == pytest.approx( expected["flat"], abs=0.01 ) - assert calculate(sim, "additional_state_pension", year)[0] == pytest.approx( - expected["additional"], abs=0.01 + assert calculate(sim, "additional_state_pension", year)[0] == approx( + expected["additional"] ) - assert calculate(sim, "state_pension", year)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + assert calculate(sim, "state_pension", year)[0] == approx( + expected["flat"] + expected["additional"] + ) + + +def test_abolishing_state_pension_still_removes_the_add_on(): + """gov.contrib.abolish_state_pension removes the whole State Pension, + additional pension and protected payments included.""" + people = [ + {"age": 70, "male": True, "months": 3, "weekly": 240}, + {"age": 90, "male": True, "months": 3, "weekly": 250}, + ] + sim = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), + reform={"gov.contrib.abolish_state_pension": True}, ) + for year in (DATA_YEAR, 2027, 2030): + assert np.all(calculate(sim, "state_pension", year) == 0) def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): @@ -460,7 +825,9 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): dataset=year_dataset([person], DATA_YEAR), reform={NSP: {"2026-01-01.2100-12-31": 300.0}}, ) - expected = expected_under_rate(240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0) - assert calculate(sim, "state_pension", 2027)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + expected = expected_under_rate( + 240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0, add_on_uprating(sim, 2027) + ) + assert calculate(sim, "state_pension", 2027)[0] == approx( + expected["flat"] + expected["additional"] ) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 932e7ff563..7ef5d894d6 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -7,6 +7,12 @@ class additional_state_pension(Variable): definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150", + "https://www.legislation.gov.uk/ukpga/1992/5/section/151A", + "https://www.legislation.gov.uk/uksi/2026/148/article/4/made", + "https://www.legislation.gov.uk/uksi/2026/148/article/6/made", + ) def formula(person, period, parameters): simulation = person.simulation @@ -29,21 +35,18 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) types = pension_type.possible_values - bsp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount - nsp_amount = parameters.gov.dwp.state_pension.new_state_pension.amount - # Each pension type has its own flat-rate ceiling; anything above # the ceiling is an add-on: # BASIC → SERPS / S2P (pre-2016 earnings-related top-up) # NEW → Protected Payment (pre-2016 accrual exceeding the # new flat rate, folded into NSP under current law) # state_pension_reported was observed under the legislated rates, so - # split it at the baseline's data-year rate, not the reformed one. - # Otherwise a reform that also sets the data year's rate (an undated - # reform, or any reform in a simulation without a dataset, where the - # data year is the period) leaves the period-over-data-year ratio at 1, - # and state_pension does not follow the reform - # (PolicyEngine/policyengine-uk#2122). + # split it at the baseline's data-year rate, not the reformed one, as + # basic_state_pension and new_state_pension do. Otherwise a reform + # that also sets the data year's rate (an undated reform, or any + # reform in a simulation without a dataset, where the data year is + # the period) would move part of the reported amount between the + # flat-rate part and the add-on (PolicyEngine/policyengine-uk#2122). baseline = getattr(simulation, "baseline", None) legislated = ( baseline.tax_benefit_system.parameters @@ -58,22 +61,25 @@ def formula(person, period, parameters): ], default=0, ) - max_for_type_period = select( - [pension_type == types.BASIC, pension_type == types.NEW], - [bsp_amount(period), nsp_amount(period)], - default=0, - ) - amount_in_data_year = where( pension_type != types.NONE, max_(reported - max_for_type_data, 0), 0, ) - uprating = where( - max_for_type_data > 0, - max_for_type_period / max_for_type_data, - 1, + # The full rates rise with the triple lock, but the add-on rises with + # prices: each year's Up-rating Order raises additional pensions and + # the amounts of new State Pension above the full rate by September + # CPI (Social Security Administration Act 1992 ss.150(1)(c), 151(1) + # and 151A; e.g. SI 2026/148 arts 4(3) and 6(3)(b), 3.8%, against + # 4.8% for the full rates). So it is uprated from the data year by + # September CPI, not by the full rate, and a reform to the full rates + # leaves it alone (PolicyEngine/policyengine-uk#1941). The index is + # read from this simulation's parameters, so a price scenario moves + # it. + september_cpi = ( + parameters.gov.economic_assumptions.indices.september_cpi_uprating ) + uprating = september_cpi(period) / september_cpi(data_year) # No State Pension is paid before State Pension age. A computed type # is already NONE there; this also holds if the type is an input. is_sp_age = person("is_SP_age", period) From 368ebb0618e7e2e43f8280b94a3ed15c20f7eb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:00:47 -0400 Subject: [PATCH 014/141] Compare float32 model output with a float32 tolerance; pass rate reforms as a reform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found two test faults (run 38161065391): the differential against main's formula compared the model's float32 arrays with a float64 reference at rtol 1e-12, which fails for any reported amount float32 cannot hold exactly (e.g. £169.00 a week, aged 98); and a Scenario cannot take the reform dictionary's "2026-01-01.2100-12-31" period, so that case passes it as a reform. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_components.py | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 8487b260aa..aeba435d94 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -363,12 +363,12 @@ def test_september_cpi_uprating_matches_the_up_rating_orders(): @pytest.mark.parametrize( - "scenario", + "change", [ # Full rates set by reform from 2026-27: the add-on is not scaled with # them. - Scenario( - parameter_changes={ + { + "reform": { "gov.dwp.state_pension.basic_state_pension.amount": { "2026-01-01.2100-12-31": 300.0 }, @@ -376,29 +376,29 @@ def test_september_cpi_uprating_matches_the_up_rating_orders(): "2026-01-01.2100-12-31": 300.0 }, } - ), + }, # A 10% triple-lock floor from April 2027 raises the full rates, not # the add-on. - Scenario( - parameter_changes={ - "gov.dwp.state_pension.triple_lock.minimum_rate": { - "year:2027-01-01:100": 0.1 + { + "scenario": Scenario( + parameter_changes={ + "gov.dwp.state_pension.triple_lock.minimum_rate": { + "year:2027-01-01:100": 0.1 + }, }, - }, - applied_before_data_load=True, - ), + applied_before_data_load=True, + ) + }, ], ids=["full_rates", "triple_lock_floor"], ) -def test_full_rate_reforms_leave_the_add_on_alone(scenario): +def test_full_rate_reforms_leave_the_add_on_alone(change): people = [ {"age": 90, "male": True, "months": 3, "weekly": 250}, {"age": 70, "male": True, "months": 3, "weekly": 240}, ] baseline = projected_simulation(people) - reformed = Microsimulation( - dataset=year_dataset(people, DATA_YEAR), scenario=scenario - ) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), **change) for year in (2027, 2028, 2030): assert calculate(reformed, "state_pension", year).sum() != pytest.approx( calculate(baseline, "state_pension", year).sum() @@ -550,6 +550,10 @@ def main_components(sim, weekly: np.ndarray, year: int) -> tuple: return tuple(WEEKS_IN_YEAR * x for x in (basic, new, additional)) +# The model computes in float32 and the reference in float64. +FLOAT32 = dict(rtol=1e-6, atol=0.01) + + def paired(people: list, extra: list) -> list: """Each person, then the same person reporting ``extra`` more a week.""" return people + [ @@ -587,10 +591,10 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): assert np.all(additional >= 0) assert np.all(additional[n:] >= additional[:n] - 1e-9) main_basic, main_new, main_additional = main_components(sim, weekly, year) - assert np.allclose(basic, main_basic, rtol=1e-12, atol=1e-9) - assert np.allclose(new, main_new, rtol=1e-12, atol=1e-9) + assert np.allclose(basic, main_basic, **FLOAT32) + assert np.allclose(new, main_new, **FLOAT32) if year == DATA_YEAR: - assert np.allclose(additional, main_additional, rtol=1e-12, atol=1e-9) + assert np.allclose(additional, main_additional, **FLOAT32) flat_up = by_type( pension_type, flat_uprating(sim, year, "BASIC"), @@ -611,7 +615,7 @@ def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): additional[same], previous_additional[same] * rise, rtol=INDEX_ROUNDING, - atol=0, + atol=1e-6, ) previous = (pension_type, additional) From 15bb98d4403b1fe24ca180be51d0d468ce5902c5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:14:03 -0400 Subject: [PATCH 015/141] Separate the CPI scenario test from the triple lock; note the impact in the docs At 5% September CPI the triple lock also rose 5%, so the scenario test passed on main. At 1%, below the triple lock's 2.5% floor, the add-on rises 1% and the full rates at least 2.5%. The docs record the change in the model's State Pension against DWP's forecast, from full runs on the certified dataset. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 8 ++++++++ policyengine_uk/tests/test_state_pension_components.py | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index e4aafcff7c..98a7f636ea 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -370,6 +370,14 @@ abroad. The model also includes Northern Ireland, which DWP's figures do not, so the like-for-like gap is larger by Northern Ireland's State Pension. +These runs predate the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941), which +uprates additional pensions and protected payments by September CPI instead of +the triple lock. On the enhanced FRS 2024-25 (policyengine-uk-data-private +1.58.0) it leaves 2024-25 unchanged and lowers the model's State Pension by +£0.3bn in 2025-26, £0.5bn in 2026-27 and £0.8bn in 2030-31, so the gap to DWP +widens by as much. + **The data year.** In 2024-25 the model has 11.36m State Pension recipients averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great Britain are 11.53m recipients averaging £212 a week, and DWP's administrative diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index aeba435d94..7586378f28 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -410,12 +410,13 @@ def test_full_rate_reforms_leave_the_add_on_alone(change): def test_a_september_cpi_scenario_moves_the_add_on(): """The index is read from the simulation's own parameters: with September - 2027 CPI set to 5%, the add-on rises 5% in April 2028.""" + 2027 CPI set to 1%, the add-on rises 1% in April 2028, while the full + rates rise by at least the triple lock's 2.5%.""" person = {"age": 90, "male": True, "months": 3, "weekly": 250} scenario = Scenario( parameter_changes={ "gov.economic_assumptions.statutory_uprating_inputs.cpi_september": { - "2027": 0.05 + "2027": 0.01 }, }, applied_before_data_load=True, @@ -425,7 +426,10 @@ def test_a_september_cpi_scenario_moves_the_add_on(): calculate(sim, "additional_state_pension", 2028)[0] / calculate(sim, "additional_state_pension", 2027)[0] ) - assert growth == pytest.approx(1.05, abs=INDEX_ROUNDING) + assert growth == pytest.approx(1.01, abs=INDEX_ROUNDING) + assert flat_uprating(sim, 2028, "BASIC") / flat_uprating(sim, 2027, "BASIC") >= ( + 1.025 - INDEX_ROUNDING + ) # Each example builds a microsimulation, so examples are slow to generate. From 4a6b736a09076e303f3e646c85f5709805c93de4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 01:44:27 -0400 Subject: [PATCH 016/141] Clarify the statutory uprating basis and forecast comparison Co-Authored-By: GPT-6 --- docs/book/programs/gov/dwp/state-pension.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 98a7f636ea..b353ff43d5 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -40,8 +40,9 @@ simulated, for anyone over State Pension age: CPI: `gov.economic_assumptions.indices.september_cpi_uprating` in the year over the data year. -That follows the law. Each year's Up-rating Order raises the full rates by -the triple lock (Social Security Administration Act 1992 s.150A), but raises +That follows the law. Each year's Up-rating Order applies the triple lock to +the full rates; the statutory minimum is earnings growth +([Social Security Administration Act 1992 s.150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A)). The Order raises additional pensions (s.150(1)(c)), increments for deferring the pension and the amounts of new State Pension above the full rate (protected payments, s.151A) by the September CPI of the year before. In April 2026 the full @@ -373,10 +374,9 @@ like-for-like gap is larger by Northern Ireland's State Pension. These runs predate the fix for [#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941), which uprates additional pensions and protected payments by September CPI instead of -the triple lock. On the enhanced FRS 2024-25 (policyengine-uk-data-private -1.58.0) it leaves 2024-25 unchanged and lowers the model's State Pension by -£0.3bn in 2025-26, £0.5bn in 2026-27 and £0.8bn in 2030-31, so the gap to DWP -widens by as much. +the triple lock. The fix leaves the data year unchanged. In projected years, +when the full rates rise faster than prices, it lowers the model's State +Pension and widens the gap to the forecasts in this table. **The data year.** In 2024-25 the model has 11.36m State Pension recipients averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great From 5230dbced97317dde7ca1e48b296ce325ddf6150 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:50:13 +0100 Subject: [PATCH 017/141] Fix benefit cap exemption defects (#1818) Four defects in the benefit cap exception logic: 1. The earnings exception subtracted total income tax liability from earnings, so tax on property, savings, dividend and pension income reduced measured net earnings. Regulation 55(5) of the Universal Credit Regulations 2013 deducts only the tax paid in respect of the employment, primary Class 1 NI and relievable pension contributions. Now uses earned_income_tax, national_insurance and pension_contributions. 2. The earnings threshold was hardcoded as 10_152 in three files. Moved to the dated parameter gov.dwp.benefit_cap_earnings_exemption, expressed monthly as reg. 82 and DWP do, with values for 2020-21 to 2026-27 derived from 16 hours a week at the National Living Wage. 3. Armed Forces Independence Payment is a statutory exception but was never checked. Added to the health/disability qualifying benefits. 4. Removed dead copy-pasted code from all three is_benefit_cap_exempt_* variables, keeping each one's return semantics unchanged apart from defect 3. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/1818.fixed.md | 4 + .../dwp/benefit_cap_earnings_exemption.yaml | 22 ++ .../benefit_cap/is_benefit_cap_exempt.yaml | 193 ++++++++++++++++++ .../gov/dwp/is_benefit_cap_exempt.py | 2 +- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 83 +++----- ...is_benefit_cap_exempt_health_disability.py | 14 +- 6 files changed, 254 insertions(+), 64 deletions(-) create mode 100644 changelog.d/1818.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml diff --git a/changelog.d/1818.fixed.md b/changelog.d/1818.fixed.md new file mode 100644 index 0000000000..4bda6ec7a6 --- /dev/null +++ b/changelog.d/1818.fixed.md @@ -0,0 +1,4 @@ +- Benefit cap earnings exception (Universal Credit Regulations 2013 reg. 82) now deducts only the tax attributable to earnings, rather than the claimant's whole income tax liability, so property, savings, dividend and pension income no longer erode net earnings and wrongly remove the exception. Relievable pension contributions are now deducted as reg. 55(5) requires. +- Moved the benefit cap earnings threshold out of three hardcoded copies of `10_152` into the dated parameter `gov.dwp.benefit_cap_earnings_exemption`, with values from 2020-21 to 2026-27 (2026-27 rises to £881 a month) derived from 16 hours a week at the National Living Wage. +- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap, as GOV.UK and the regulations provide. The variable has no dataset source, so this changes household calculations only. +- Removed dead copy-pasted code from the three `is_benefit_cap_exempt_*` variables, which computed exemption conditions that were never returned. diff --git a/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml new file mode 100644 index 0000000000..ac9d86ca47 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml @@ -0,0 +1,22 @@ +description: Monthly earned income at or above which a Universal Credit claimant (or couple, jointly) is excepted from the benefit cap. Set by regulation 82 of the Universal Credit Regulations 2013 as 16 hours per week at the National Living Wage rate in regulation 4 of the National Minimum Wage Regulations 2015, converted to a monthly amount by multiplying by 52 and dividing by 12, and rounded down to whole pounds by DWP. Earned income for this test is net of income tax, National Insurance contributions and relievable pension contributions on the earnings, and excludes income treated as earned under the minimum income floor. +values: + 2020-04-01: 604 + 2021-04-01: 617 + 2022-04-01: 658 + 2023-04-01: 722 + 2024-04-01: 793 + 2025-04-01: 846 + 2026-04-01: 881 +metadata: + label: Benefit cap earnings exemption threshold + unit: currency-GBP + period: month + reference: + - title: The Universal Credit Regulations 2013 reg. 82 (Exceptions - earnings) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 55 (Employed earnings) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 + - title: GOV.UK - Benefit cap - When you're not affected + href: https://www.gov.uk/benefit-cap/when-youre-not-affected + - title: GOV.UK - National Minimum Wage and National Living Wage rates + href: https://www.gov.uk/national-minimum-wage-rates diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml new file mode 100644 index 0000000000..10f03798d6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml @@ -0,0 +1,193 @@ +# Benefit cap exceptions, Universal Credit Regulations 2013 regs. 82-83. +# https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.gov.uk/benefit-cap/when-youre-not-affected + +# --- Earnings exception: only tax on the earnings is deducted (reg. 55(5)) --- + +- name: Unearned income does not reduce net earnings for the earnings exception + # Regulation 55(5) deducts the income tax paid "in respect of the + # employment", not the claimant's whole income tax liability. Property + # income of 40,000 pushes total income tax to over 8,000, but the tax + # attributable to the 13,000 of earnings is under 100, so net earnings stay + # well above the 846/month threshold and the exception applies. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 13_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: true + is_benefit_cap_exempt: true + +- name: Earnings below the threshold do not except, even with large unearned income + # The same unearned income with earnings of 8,000 leaves net earnings below + # 846 x 12 = 10,152, so the exception does not apply. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 8_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: false + +- name: A couple's earnings are combined for the earnings exception + # Reg. 82(1)(a) applies to "the claimant's and their partner's combined + # earned income". Neither partner alone reaches 10,152; together they do. + period: 2025 + input: + people: + person1: + age: 35 + employment_income: 6_000 + person2: + age: 35 + employment_income: 6_000 + benunits: + benunit: + members: [person1, person2] + output: + is_benefit_cap_exempt_earnings: true + +- name: Relievable pension contributions reduce earned income + # Reg. 55(5)(a) deducts relievable pension contributions from employed + # earnings, so a contribution that takes net earnings below the threshold + # removes the exception. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 11_000 + employee_pension_contributions: 1_500 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: false + +# --- Earnings threshold is a dated parameter, not a hardcoded constant --- + +- name: The 2025-26 earnings threshold is 846 a month + period: 2025 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # 10,300 clears 846 x 12 = 10,152. + is_benefit_cap_exempt_earnings: true + +- name: The 2026-27 earnings threshold rises to 881 a month + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # The same earnings fall short of 881 x 12 = 10,572. + is_benefit_cap_exempt_earnings: false + +- name: Earnings above the 2026-27 threshold except from the cap + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_600 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: true + +# --- Armed Forces Independence Payment is a statutory exception --- + +- name: Armed Forces Independence Payment excepts from the benefit cap + period: 2026 + input: + people: + person: + age: 35 + armed_forces_independence_payment: 5_740 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_health_disability: true + is_benefit_cap_exempt: true + +- name: Without Armed Forces Independence Payment the cap applies + period: 2026 + input: + people: + person: + age: 35 + armed_forces_independence_payment: 0 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + +- name: A child's Armed Forces Independence Payment excepts the benefit unit + period: 2026 + input: + people: + parent: + age: 35 + child: + age: 10 + armed_forces_independence_payment: 5_740 + benunits: + benunit: + members: [parent, child] + output: + is_benefit_cap_exempt_health_disability: true + +# --- Other exceptions are unchanged --- + +- name: State pension age excepts from the benefit cap + period: 2026 + input: + people: + person: + age: 70 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_other: true + is_benefit_cap_exempt: true + +- name: Armed Forces Compensation Scheme payments except from the benefit cap + period: 2026 + input: + people: + person: + age: 35 + afcs_reported: 2_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_other: true + is_benefit_cap_exempt_health_disability: true diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py index 563b340854..6bd5018997 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py @@ -4,7 +4,7 @@ class is_benefit_cap_exempt(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap because of health or disability" + label = "Whether exempt from the benefits cap" definition_period = YEAR reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 1bdb797d6a..45019d5150 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -4,61 +4,42 @@ class is_benefit_cap_exempt_earnings(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap for non-health/disability reasons" + label = "Whether exempt from the benefits cap because of earnings" definition_period = YEAR - reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/82" def formula(benunit, period, parameters): + # Regulation 82 of the Universal Credit Regulations 2013 excepts a + # claimant (or couple, on their combined income) from the benefit cap + # where their earned income reaches 16 hours a week at the National + # Living Wage. Earned income here is employed and self-employed + # earnings net of the income tax, National Insurance and relievable + # pension contributions attributable to those earnings (reg. 55(5), + # reg. 57), not net of total tax on all income. + # + # Reg. 82(1)(a) tests "the claimant's earned income or, if the + # claimant is a member of a couple, the couple's combined earned + # income": a dependant's earnings do not count. + # + # Only earned income is read, not the Universal Credit award itself, + # to avoid a circular dependency with the cap. person = benunit.members - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, - # if the claimant is a member of a couple, the couple's combined - # earned income": a dependant's earnings do not count. claimant = person("is_uc_assessed_claimant", period) - uc_earned = benunit.sum( - ( - person("employment_income", period) - + person("self_employment_income", period) - - person("income_tax", period) - - person("national_insurance", period) - ) - * claimant + earnings = person("employment_income", period) + person( + "self_employment_income", period ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # ESA and Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 - - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 + # earned_income_tax is the tax on non-savings, non-dividend income, + # which excludes property, savings and dividend income. It is the + # closest available measure of tax on earnings; it still includes tax + # on private and state pension income, which is the approximation we + # accept here (households with pension income are generally already + # exempt through the state pension age exception). + deductions = ( + person("earned_income_tax", period) + + person("national_insurance", period) + + person("pension_contributions", period) + ) + net_earnings = max_(0, benunit.sum((earnings - deductions) * claimant)) - return meets_earnings_test + monthly_threshold = parameters(period).gov.dwp.benefit_cap_earnings_exemption + return net_earnings >= monthly_threshold * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 69e1116338..8cb9885693 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -22,7 +22,6 @@ def formula(benunit, period, parameters): # not lift the cap. not_a_claimant = other_member_of_single_claim_in_shared_rules(person, period) - # UC-specific exemptions # Limited capability for work and work-related activity has_lcwra = benunit.any( person("uc_limited_capability_for_WRA", period) & ~not_a_claimant @@ -31,17 +30,6 @@ def formula(benunit, period, parameters): # Carer element in UC indicates caring for someone with disability gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) - ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - # Disability and carer benefits that exempt from cap QUAL_PERSONAL_BENEFITS = [ "attendance_allowance", @@ -54,6 +42,8 @@ def formula(benunit, period, parameters): "pip_dl", # PIP daily living component "pip_m", # PIP mobility component "iidb", # Industrial injuries disability benefit + # Armed Forces Independence Payment is a statutory exemption + "armed_forces_independence_payment", ] # ESA and Working Tax Credit From 4a5174c8cdcd33fb374b42c57fec118eaf907117 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 15:51:17 +0100 Subject: [PATCH 018/141] Drop the strict xfail on the dividend benefit cap case now that #1818's fix makes it pass The earnings exception to the benefit cap now nets only earned_income_tax off earnings, so tax on dividends no longer removes it (#1986). Co-Authored-By: Claude Opus 5.5 --- .../test_uc_income_from_capital_properties.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 4a07d757fd..fe9c64ca20 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -33,10 +33,12 @@ s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is -also unchanged: the model's earnings exception to the cap still nets tax on -all of a person's income off their earnings -(PolicyEngine/policyengine-uk#1986), so capital income can switch the cap on. -A strict xfail pins that case. +also unchanged. The cap's earnings exception now nets only the tax on +non-savings, non-dividend income off earnings (PolicyEngine/policyengine-uk#1986), +so savings and dividend income leave it alone, but property income is taxed in +that same band and can still switch the cap on. +test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception pins +the dividend case. """ import numpy as np @@ -323,13 +325,6 @@ def test_tax_on_dividends_does_not_raise_a_working_familys_award(): assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1986: the benefit cap earnings exception " - "nets tax on all of a person's income off their earnings" - ), -) def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): # 2026: single claimant aged 30 in London, council rent 20,000, earning # 11,000 with no tax or NI on it. Without dividends the model applies no From 7b1124e0f9ece1551407ae45e2e1e91b91f96b39 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 18 Sep 2026 15:53:25 +0100 Subject: [PATCH 019/141] Uprate three more columns, and guard the gap that hid them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1860, closing most of #1862. Three variables declared an uprating index but were missing from uprating_indices.yaml, so they were never uprated. On enhanced_frs_2024_25, 2024 -> 2027: water_and_sewerage_charges 375.51 -> 522.12 (+39.0%) housing_service_charges 91.81 -> 105.06 (+14.4%) pension_contributions_via_salary_sacrifice 418.69 -> 466.81 (+11.5%) Each matches its index compounded over the same years exactly: ofwat water bills +39.0%, OBR lagged average earnings +14.4%, OBR average earnings +11.5%. All three indices already had the yoy_growth twin the YAML needs, so this is a YAML-only change. rail_usage is deliberately NOT included. Its declared index, gov.dft.rail.ridership_index, is a CUMULATIVE index with 2020 = 1.0 — "Used for uprating the quantity component of rail subsidy spending" — not a year-on-year rate. Adding it to a yoy_growth block compounds the level and uprates the variable by about 684% over 2024-27. Uprating it properly needs a year-on-year series, which is more than a YAML entry. The durable fix is the code-health test suggested in review of #1860: policyengine_uk/tests/code_health/test_uprating_declarations.py asserts that every variable declaring `uprating` appears in the YAML under the matching index. Verified it fails when an entry is removed, rather than only passing today. Three exceptions are listed with reasons rather than silently tolerated: rail_usage and employee_pension_contributions_reported as not uprated, and savings_interest_income as uprated by a different index from the one it declares — the mismatch noted in #1862, left for someone with the context to say which is intended. A third test keeps those lists honest by asserting each entry still exists and still declares uprating. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/uprate-four-remaining-columns.md | 1 + policyengine_uk/data/uprating_indices.yaml | 5 + .../code_health/test_uprating_declarations.py | 95 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 changelog.d/uprate-four-remaining-columns.md create mode 100644 policyengine_uk/tests/code_health/test_uprating_declarations.py diff --git a/changelog.d/uprate-four-remaining-columns.md b/changelog.d/uprate-four-remaining-columns.md new file mode 100644 index 0000000000..d9cc6d2152 --- /dev/null +++ b/changelog.d/uprate-four-remaining-columns.md @@ -0,0 +1 @@ +- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is deliberately excluded: its declared index is a cumulative level rather than a year-on-year rate. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 7cebef7f9a..53b15233fa 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -32,6 +32,7 @@ gov.economic_assumptions.yoy_growth.obr.average_earnings: - employer_pension_contributions - employment_income - employment_income_before_lsr +- pension_contributions_via_salary_sacrifice - personal_pension_contributions - student_loan_repayments gov.economic_assumptions.yoy_growth.obr.consumer_price_index: @@ -126,5 +127,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income +gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: +- housing_service_charges +gov.economic_assumptions.yoy_growth.ofwat.water_bills: +- water_and_sewerage_charges gov.economic_assumptions.yoy_growth.ons.population: - household_weight diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py new file mode 100644 index 0000000000..df7d782178 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -0,0 +1,95 @@ +"""Every variable declaring `uprating` should appear in uprating_indices.yaml. + +The class-level `uprating = "..."` attribute on a Variable is dead metadata: +nothing in policyengine-uk reads it, and policyengine-core only inspects it to +refuse combining it with its own `@uprated` decorator. `uprating_indices.yaml` +is the only mechanism that applies uprating, via `apply_single_year_uprating` +and `reset_growthfactor_uprating` in `data/economic_assumptions.py`. + +A variable can therefore declare an index and silently never be uprated. That +is what #1859 found for electricity and gas consumption, and #1862 for three +more columns. This guard makes the gap fail loudly at the point it is +introduced rather than surfacing in a downstream study. +""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +UPRATING_INDICES = ( + Path(__file__).parents[2] / "data" / "uprating_indices.yaml" +) + +# Variables that declare an index but are deliberately not in the YAML, with +# the reason. Anything else failing this test is a genuine gap. +KNOWN_UNUPRATED = { + # gov.dft.rail.ridership_index is a CUMULATIVE index (2020 = 1.0), not a + # year-on-year growth rate, so it cannot go in a yoy_growth list without + # compounding wrongly: doing so uprates rail_usage by ~684% over 2024-27. + # Uprating this needs a yoy series, not a YAML entry (#1862). + "rail_usage", + # Reported counterpart of employee_pension_contributions, which IS + # uprated; the reported variable feeds imputation rather than results. + "employee_pension_contributions_reported", +} + +# Variables the YAML deliberately uprates by an index other than the one they +# declare, with the reason. +KNOWN_INDEX_OVERRIDES = { + # Declares ons.household_interest_income; the YAML applies per_capita.gdp. + # Flagged in #1862 for someone with the context to say which is intended. + "savings_interest_income", +} + + +def _listed_indices(): + listed = yaml.safe_load(UPRATING_INDICES.read_text()) + return { + variable: index for index, variables in listed.items() for variable in variables + } + + +def test_declared_uprating_variables_are_in_the_yaml(): + listed = _listed_indices() + missing = sorted( + name + for name, variable in CountryTaxBenefitSystem().variables.items() + if getattr(variable, "uprating", None) + and name not in listed + and name not in KNOWN_UNUPRATED + ) + assert not missing, ( + "these variables declare `uprating` but are absent from " + f"uprating_indices.yaml, so they are never uprated: {missing}. Add " + "them to the matching index block, or to KNOWN_UNUPRATED with a reason." + ) + + +def test_the_yaml_uprates_by_the_declared_index(): + listed = _listed_indices() + mismatched = [] + for name, variable in CountryTaxBenefitSystem().variables.items(): + declared = getattr(variable, "uprating", None) + if not declared or name not in listed or name in KNOWN_INDEX_OVERRIDES: + continue + # The YAML keys are yoy_growth twins of the declared indices. + equivalent = declared.replace(".indices.", ".yoy_growth.") + if listed[name] not in (declared, equivalent): + mismatched.append((name, declared, listed[name])) + assert not mismatched, ( + "these variables are uprated by a different index from the one they " + f"declare: {mismatched}" + ) + + +def test_every_known_exception_still_declares_uprating(): + """Keeps the exception lists honest: an entry that no longer declares + uprating, or no longer exists, should be removed rather than left.""" + variables = CountryTaxBenefitSystem().variables + for name in KNOWN_UNUPRATED | KNOWN_INDEX_OVERRIDES: + assert name in variables, f"{name} no longer exists; drop the exception" + assert getattr(variables[name], "uprating", None), ( + f"{name} no longer declares uprating; drop the exception" + ) From 7ad7fa77382c063d8dd26e3dccbc52bd09434c47 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 7 Oct 2026 10:37:13 +0100 Subject: [PATCH 020/141] Address review: format the guard test, type the changelog fragment, close the gaps it now finds - ruff format test_uprating_declarations.py (the Lint failure). - Rename the fragment to uprate-four-remaining-columns.fixed.md so towncrier renders it under Fixed rather than dropping it. - After rebasing onto main, the guard flags ten variables declared since: seven are genuine gaps and now uprate by the index they declare (cash_isa, stocks_and_shares_isa, mortgage_debt, consumer_debt, rent_paid_as_boarder, rent_paid_as_lodger by per-capita GDP; self_employment_gross_receipts by per-capita mixed income); the three energy columns are listed as deliberately unuprated, per #1868. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uprate-four-remaining-columns.fixed.md | 1 + changelog.d/uprate-four-remaining-columns.md | 1 - policyengine_uk/data/uprating_indices.yaml | 7 +++++++ .../tests/code_health/test_uprating_declarations.py | 10 +++++++--- 4 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 changelog.d/uprate-four-remaining-columns.fixed.md delete mode 100644 changelog.d/uprate-four-remaining-columns.md diff --git a/changelog.d/uprate-four-remaining-columns.fixed.md b/changelog.d/uprate-four-remaining-columns.fixed.md new file mode 100644 index 0000000000..2ba4360370 --- /dev/null +++ b/changelog.d/uprate-four-remaining-columns.fixed.md @@ -0,0 +1 @@ +- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Also uprate seven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder` and `rent_paid_as_lodger` (per-capita GDP) and `self_employment_gross_receipts` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). diff --git a/changelog.d/uprate-four-remaining-columns.md b/changelog.d/uprate-four-remaining-columns.md deleted file mode 100644 index d9cc6d2152..0000000000 --- a/changelog.d/uprate-four-remaining-columns.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is deliberately excluded: its declared index is a cumulative level rather than a year-on-year rate. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 53b15233fa..115e7b4ed8 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -120,11 +120,18 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - savings - savings_interest_income - sublet_income +- cash_isa +- consumer_debt +- mortgage_debt +- rent_paid_as_boarder +- rent_paid_as_lodger +- stocks_and_shares_isa # A formula of benunit_reported_capital; listed so a dataset that stores it # directly is uprated like the input. - uc_reported_capital gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income +- self_employment_gross_receipts gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index df7d782178..2918f52812 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -18,9 +18,7 @@ from policyengine_uk import CountryTaxBenefitSystem -UPRATING_INDICES = ( - Path(__file__).parents[2] / "data" / "uprating_indices.yaml" -) +UPRATING_INDICES = Path(__file__).parents[2] / "data" / "uprating_indices.yaml" # Variables that declare an index but are deliberately not in the YAML, with # the reason. Anything else failing this test is a genuine gap. @@ -33,6 +31,12 @@ # Reported counterpart of employee_pension_contributions, which IS # uprated; the reported variable feeds imputation rather than results. "employee_pension_contributions_reported", + # policyengine-uk-data stores energy spend already priced at the dataset's + # target price level (Ofgem Q2 2026 unit rates), so CPI-uprating from the + # data year re-applies price changes the build has priced in (#1868). + "domestic_energy_consumption", + "electricity_consumption", + "gas_consumption", } # Variables the YAML deliberately uprates by an index other than the one they From d150116d0d08c9b9db2234f348dd38c84ea816e1 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 7 Oct 2026 14:12:30 +0100 Subject: [PATCH 021/141] Make the uprating exception guard fail on stale entries The exception check only confirmed each entry still existed and declared `uprating`. It now also fails when a KNOWN_UNUPRATED variable has been added to uprating_indices.yaml, or when a KNOWN_INDEX_OVERRIDES variable is now uprated by its declared index (or has left the YAML), naming the list to edit. Unit tests feed the guard stale entries to prove each case is caught. Co-Authored-By: Claude Opus 5.5 --- .../code_health/test_uprating_declarations.py | 103 ++++++++++++++++-- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index 2918f52812..11931080dd 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -71,6 +71,12 @@ def test_declared_uprating_variables_are_in_the_yaml(): ) +def _uprated_by_another_index(declared, listed_index): + # The YAML keys are yoy_growth twins of the declared indices. + equivalent = declared.replace(".indices.", ".yoy_growth.") + return listed_index not in (declared, equivalent) + + def test_the_yaml_uprates_by_the_declared_index(): listed = _listed_indices() mismatched = [] @@ -78,9 +84,7 @@ def test_the_yaml_uprates_by_the_declared_index(): declared = getattr(variable, "uprating", None) if not declared or name not in listed or name in KNOWN_INDEX_OVERRIDES: continue - # The YAML keys are yoy_growth twins of the declared indices. - equivalent = declared.replace(".indices.", ".yoy_growth.") - if listed[name] not in (declared, equivalent): + if _uprated_by_another_index(declared, listed[name]): mismatched.append((name, declared, listed[name])) assert not mismatched, ( "these variables are uprated by a different index from the one they " @@ -88,12 +92,87 @@ def test_the_yaml_uprates_by_the_declared_index(): ) -def test_every_known_exception_still_declares_uprating(): - """Keeps the exception lists honest: an entry that no longer declares - uprating, or no longer exists, should be removed rather than left.""" - variables = CountryTaxBenefitSystem().variables - for name in KNOWN_UNUPRATED | KNOWN_INDEX_OVERRIDES: - assert name in variables, f"{name} no longer exists; drop the exception" - assert getattr(variables[name], "uprating", None), ( - f"{name} no longer declares uprating; drop the exception" - ) +def _stale_exceptions(variables, listed, unuprated, index_overrides): + """Return a message for every exception whose reason no longer holds.""" + stale = [] + for name in sorted(unuprated | index_overrides): + if name not in variables: + stale.append(f"{name} no longer exists; drop the exception") + elif not getattr(variables[name], "uprating", None): + stale.append(f"{name} no longer declares uprating; drop the exception") + for name in sorted(unuprated & variables.keys()): + if name in listed: + stale.append( + f"{name} is now in uprating_indices.yaml under {listed[name]}; " + "remove it from KNOWN_UNUPRATED" + ) + for name in sorted(index_overrides & variables.keys()): + declared = getattr(variables[name], "uprating", None) + if not declared: + continue + if name not in listed: + stale.append( + f"{name} is no longer in uprating_indices.yaml, so there is no " + "index to override; remove it from KNOWN_INDEX_OVERRIDES" + ) + elif not _uprated_by_another_index(declared, listed[name]): + stale.append( + f"{name} is now uprated by its declared index ({declared}); " + "remove it from KNOWN_INDEX_OVERRIDES" + ) + return stale + + +def test_every_known_exception_still_holds(): + """Keeps the exception lists honest: an entry whose reason no longer + holds should be removed rather than left to hide a future gap.""" + stale = _stale_exceptions( + CountryTaxBenefitSystem().variables, + _listed_indices(), + KNOWN_UNUPRATED, + KNOWN_INDEX_OVERRIDES, + ) + assert not stale, "stale uprating exceptions:\n" + "\n".join(stale) + + +class _Declares: + def __init__(self, uprating=None): + self.uprating = uprating + + +CPI = "gov.economic_assumptions.indices.obr.consumer_price_index" +CPI_YOY = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +GDP_YOY = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" + + +def test_the_guard_passes_exceptions_that_still_hold(): + variables = {"a": _Declares(CPI), "b": _Declares(CPI)} + listed = {"b": GDP_YOY} + assert _stale_exceptions(variables, listed, {"a"}, {"b"}) == [] + + +def test_the_guard_flags_an_unuprated_exception_added_to_the_yaml(): + variables = {"a": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {"a": CPI_YOY}, {"a"}, set()) + assert "remove it from KNOWN_UNUPRATED" in stale + + +def test_the_guard_flags_an_override_that_now_matches(): + variables = {"b": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {"b": CPI_YOY}, set(), {"b"}) + assert "remove it from KNOWN_INDEX_OVERRIDES" in stale + + +def test_the_guard_flags_an_override_dropped_from_the_yaml(): + variables = {"b": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {}, set(), {"b"}) + assert "remove it from KNOWN_INDEX_OVERRIDES" in stale + + +def test_the_guard_flags_missing_or_undeclared_exceptions(): + variables = {"a": _Declares(None)} + stale = _stale_exceptions(variables, {}, {"a", "gone"}, set()) + assert stale == [ + "a no longer declares uprating; drop the exception", + "gone no longer exists; drop the exception", + ] From 70e426c01768ceb08c379cd877154c0d6bc31819 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 12:04:06 +0200 Subject: [PATCH 022/141] Limit the AFIP benefit cap exception to the people the regulations name A child's Armed Forces Independence Payment excepted the whole benefit unit, because AFIP was summed over every member. HB Regs 2006 reg 75F(1)(ea) names the claimant, partner or a young person they are responsible for, not a child under 16; for Universal Credit AFIP is within "attendance allowance" (UC Regs 2013 reg 2), which excepts only where a claimant receives it (reg 83(1)(c)). AFIP now counts for the claimant, partner or a Housing Benefit young person. The child test now expects false, with a new young-person test. The AFCS test drops its is_benefit_cap_exempt_other assertion, which #2076 removes when it moves AFCS into is_benefit_cap_exempt_health_disability, and asserts is_benefit_cap_exempt instead. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1818.fixed.md | 2 +- .../benefit_cap/is_benefit_cap_exempt.yaml | 35 +++++++++++++++++-- ...is_benefit_cap_exempt_health_disability.py | 25 +++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/changelog.d/1818.fixed.md b/changelog.d/1818.fixed.md index 4bda6ec7a6..f43c7ef0de 100644 --- a/changelog.d/1818.fixed.md +++ b/changelog.d/1818.fixed.md @@ -1,4 +1,4 @@ - Benefit cap earnings exception (Universal Credit Regulations 2013 reg. 82) now deducts only the tax attributable to earnings, rather than the claimant's whole income tax liability, so property, savings, dividend and pension income no longer erode net earnings and wrongly remove the exception. Relievable pension contributions are now deducted as reg. 55(5) requires. - Moved the benefit cap earnings threshold out of three hardcoded copies of `10_152` into the dated parameter `gov.dwp.benefit_cap_earnings_exemption`, with values from 2020-21 to 2026-27 (2026-27 rises to £881 a month) derived from 16 hours a week at the National Living Wage. -- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap, as GOV.UK and the regulations provide. The variable has no dataset source, so this changes household calculations only. +- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap when the claimant, their partner or a Housing Benefit young person receives it (HB Regs 2006 reg 75F(1)(ea); UC Regs 2013 regs 2 and 83(1)(c)), not a child under 16. The variable has no dataset source, so this changes household calculations only. - Removed dead copy-pasted code from the three `is_benefit_cap_exempt_*` variables, which computed exemption conditions that were never returned. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml index 10f03798d6..c58cb20870 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml @@ -148,7 +148,11 @@ is_benefit_cap_exempt_health_disability: false is_benefit_cap_exempt: false -- name: A child's Armed Forces Independence Payment excepts the benefit unit +- name: A child's Armed Forces Independence Payment does not except the benefit unit + # HB Regs 2006 reg 75F(1)(ea) names the claimant, partner or a young person + # the claimant is responsible for, not a child under 16. For Universal + # Credit, AFIP is within "attendance allowance" (UC Regs 2013 reg 2), which + # excepts only where "a claimant is receiving" it (reg 83(1)(c)). period: 2026 input: people: @@ -161,6 +165,26 @@ benunit: members: [parent, child] output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + +- name: A young person's Armed Forces Independence Payment excepts the benefit unit + # HB Regs 2006 reg 75F(1)(ea) names a young person the claimant is + # responsible for: a Child Benefit qualifying young person (reg 19). + period: 2026 + input: + people: + parent: + age: 45 + young_person: + age: 17 + current_education: UPPER_SECONDARY + armed_forces_independence_payment: 5_740 + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_child_benefit: [false, true] is_benefit_cap_exempt_health_disability: true # --- Other exceptions are unchanged --- @@ -179,6 +203,13 @@ is_benefit_cap_exempt: true - name: Armed Forces Compensation Scheme payments except from the benefit cap + # A guaranteed income payment (article 15(1)(c) of the Armed Forces and + # Reserve Forces (Compensation Scheme) Order 2011) and a war disablement + # pension except: UC Regs 2013 reg 83(1)(d)-(e); HB Regs 2006 reg + # 75F(1)(d), with reg 75F(2)(a). This asserts the health and disability + # exception and the overall result, not is_benefit_cap_exempt_other, which + # also checks afcs today but loses it when #2076 moves AFCS into + # is_benefit_cap_exempt_health_disability. period: 2026 input: people: @@ -189,5 +220,5 @@ benunit: members: [person] output: - is_benefit_cap_exempt_other: true is_benefit_cap_exempt_health_disability: true + is_benefit_cap_exempt: true diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 8cb9885693..1e5b132eff 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -42,8 +42,6 @@ def formula(benunit, period, parameters): "pip_dl", # PIP daily living component "pip_m", # PIP mobility component "iidb", # Industrial injuries disability benefit - # Armed Forces Independence Payment is a statutory exemption - "armed_forces_independence_payment", ] # ESA and Working Tax Credit @@ -57,6 +55,28 @@ def formula(benunit, period, parameters): ) qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) + # Armed Forces Independence Payment counts only for the people the + # regulations name. Housing Benefit: "the claimant, the claimant's + # partner or a young person for whom the claimant or the claimant's + # partner is responsible" (HB Regs 2006 reg 75F(1)(ea)), so not a + # child under 16. Universal Credit: AFIP is within "attendance + # allowance" (UC Regs 2013 reg 2), which excepts only where "a + # claimant is receiving" it (reg 83(1)(c)). The model applies one + # cap to both schemes, so the claimant, partner or a Housing Benefit + # young person counts. + housing_benefit_young_person = person( + "is_child_or_young_person_for_legacy_benefits", period + ) & person("is_qualifying_young_person_for_child_benefit", period) + afip_counted = ( + person("is_claimant_or_partner", period) & ~not_a_claimant + ) | housing_benefit_young_person + afip = ( + add_for_members( + benunit, period, ["armed_forces_independence_payment"], afip_counted + ) + > 0 + ) + # Check for Armed Forces Compensation Scheme payments afcs = add_for_members(benunit, period, ["afcs"], ~not_a_claimant) > 0 @@ -70,6 +90,7 @@ def formula(benunit, period, parameters): | gets_uc_carer_element | (qualifying_personal_benefits > 0) | (qualifying_benunit_benefits > 0) + | afip | afcs | esa_support_component ) From 7ab0afa3325fc78d040be74f6106237c667583a7 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:56:09 +0200 Subject: [PATCH 023/141] Use published Ofwat bills for 2025-26 and 2026-27, and document both series' provenance Water bill growth for 2025-26 becomes 27%, the figure in Ofwat's 29 January 2026 statement, replacing its 30 January 2025 forecast of 26%. 2026-27 becomes 5.4%, Water UK's 28 January 2026 national average, replacing a 5.5% projection. The 2027-2029 projections are kept and their method is recorded, including that their inputs were never committed. ons.population is documented as the OBR long-term economic determinants for the March 2025 EFO (19 June 2025), with the ONS 2022-based migration category variant from 2030-31, and the per-capita series note that they use it. Co-Authored-By: Claude Opus 5.5 --- .../uprate-four-remaining-columns.fixed.md | 2 +- docs/book/assumptions/water-bills.md | 28 +++++----- .../gov/economic_assumptions/yoy_growth.yaml | 54 ++++++++++++++++--- .../tests/test_water_bills_growth.py | 25 +++++++++ 4 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 policyengine_uk/tests/test_water_bills_growth.py diff --git a/changelog.d/uprate-four-remaining-columns.fixed.md b/changelog.d/uprate-four-remaining-columns.fixed.md index 2ba4360370..2deb67266f 100644 --- a/changelog.d/uprate-four-remaining-columns.fixed.md +++ b/changelog.d/uprate-four-remaining-columns.fixed.md @@ -1 +1 @@ -- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Also uprate seven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder` and `rent_paid_as_lodger` (per-capita GDP) and `self_employment_gross_receipts` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). +- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Also uprate seven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder` and `rent_paid_as_lodger` (per-capita GDP) and `self_employment_gross_receipts` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index 990944a734..bdc0027549 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -65,15 +65,19 @@ We store water bills year-on-year growth rates in: ### Growth rates -The model uses these resulting year-on-year growth rates: - -| Year | Growth rate | -|------|-------------| -| 2022 | 5.2% | -| 2023 | 9.2% | -| 2024 | 4.4% | -| 2025 | 6.1% | -| 2026 | 6.1% | -| 2027 | 5.1% | -| 2028 | 3.8% | -| 2029 | 4.3% | +The model uses these year-on-year growth rates. Each year is the charging year starting in April, so 2025 is 2025-26. + +| Year | Growth rate | Source | +|------|-------------|--------| +| 2022 | 5.2% | Ofwat average bills, real bills converted to nominal with CPIH | +| 2023 | 9.2% | As above | +| 2024 | 4.4% | As above | +| 2025 | 27% | [Ofwat statement, 29 January 2026](https://www.ofwat.gov.uk/ofwat-responds-to-water-sector-average-bill-figures/) (later figure; Ofwat's [30 January 2025 statement](https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/) forecast 26%) | +| 2026 | 5.4% | [Water UK, 28 January 2026](https://www.water.org.uk/news-views-publications/news/record-investment-average-household-water-bills-rise-ps33-year-april): national average £639, up £33 | +| 2027 | 4.6% | Projection from `forecast_water_bills.py` | +| 2028 | 4.5% | As above | +| 2029 | 4.5% | As above | + +```{warning} +The 2027-2029 projections cannot be reproduced from the repository. They take the CCW table, which reflects Ofwat's PR24 *draft* determinations rather than the final ones, weight each company by customer numbers, and add a hardcoded CPIH path. The customer weights were in `ofwat_increases.csv`, which is gitignored and was never committed. +``` diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index e21a645890..68c52526c7 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -655,7 +655,9 @@ obr: 2022-01-01: 0.1019 2023-01-01: 0.0532 2024-01-01: 0.0372 - # Forecast (2025-2030) from OBR EFO March 2026, Table A.1 nominal GDP minus population growth + # Forecast (2025-2030) from OBR EFO March 2026, Table A.1 nominal GDP minus population growth. + # Population growth here is ons.population (March 2025 vintage), not the + # lower growth implied by the March 2026 EFO; see ons.population. 2025-01-01: 0.0438 2026-01-01: 0.0292 2027-01-01: 0.0323 @@ -678,6 +680,7 @@ obr: 2023-01-01: -0.0060 2024-01-01: 0.0273 # Forecast (2025-2030) from OBR EFO March 2026, Table 1.12 minus population growth + # (ons.population, March 2025 vintage; see ons.population). 2025-01-01: 0.0069 2026-01-01: 0.0306 2027-01-01: 0.0371 @@ -700,6 +703,7 @@ obr: 2023-01-01: 0.1084 2024-01-01: 0.0385 # Forecast (2025-2030) from OBR EFO March 2026, Table 1.12 minus population growth + # (ons.population, March 2025 vintage; see ons.population). 2025-01-01: 0.0275 2026-01-01: 0.0358 2027-01-01: 0.0474 @@ -816,6 +820,19 @@ obr: ons: population: description: Population year-on-year growth. + # 2022-2073 is the population growth row of the OBR's long-term economic + # determinants for the March 2025 EFO, published 19 June 2025 (#1305). + # The fiscal year starting in year t is stored at t-01-01, rounded to + # four decimals (2025-26's 0.7230% is 2025-01-01: 0.0072). The OBR notes + # that readings are at the start of the fiscal year or mid-year. To + # 2029-30 the series is the OBR's March 2025 forecast; from 2030-31 it is + # the migration category variant of the ONS 2022-based national + # population projections. The 2021 value predates #1305 and has no + # recorded source. This series is a March 2025 vintage. Calendar-year + # population growth implied by the March 2026 EFO economy tables (real GDP + # in Table 1.1 over real GDP per person in Table 1.5) is lower: 0.37% in + # 2025, against 0.72% here, and 0.30% to 0.36% a year in 2026-2030. The + # ONS has since published 2024-based projections (28 April 2026). values: 2021-01-01: 0.004 2022-01-01: 0.0093 @@ -874,8 +891,10 @@ ons: unit: /1 label: Population growth reference: - - title: ONS Population Projections - href: https://www.ons.gov.uk/ + - title: OBR, "Long-term economic determinants - March 2025 Economic and fiscal outlook" (19 June 2025), population growth row + href: https://obr.uk/supplementary-forecast-information-on-long-term-economic-determinants-and-personal-independence-payment-policy-costing/ + - title: ONS, "National population projections - 2022-based" (migration category variant, used by the OBR from 2030-31) + href: https://www.ons.gov.uk/peoplepopulationandcommunity/populationandmigration/populationprojections/bulletins/nationalpopulationprojections/2022based household_interest_income: description: Household interest income year-on-year growth from ONS National Accounts D.41g. values: @@ -1191,12 +1210,27 @@ ons: ofwat: water_bills: description: Water and sewerage bills year-on-year growth. + # Growth in the average household water and sewerage bill in England and + # Wales. Each value is the charging year starting in April, so 2025-01-01 + # holds 2025-26. + # - 2022-2024: nominal growth in Ofwat's average bill, from the real + # bills and CPIH in policyengine_uk/utils/water/forecast_water_bills.py. + # - 2025 (2025-26): 27%, the increase Ofwat reports for 2025-26 in its + # 29 January 2026 statement. It replaces the 26% forecast in Ofwat's + # 30 January 2025 statement. + # - 2026 (2026-27): 5.4%, the national average from Water UK's 28 January + # 2026 company charges (GBP 639, up GBP 33), confirmed by Ofwat the next day. + # - 2027-2029: projections from forecast_water_bills.py (#1239). They are + # the customer-weighted mean of per-company bills before inflation, from + # the CCW table of Ofwat's PR24 draft determinations, plus a hardcoded + # CPIH path. The weights were in ofwat_increases.csv, which was never + # committed, so these three values cannot be reproduced from the repo. values: 2022-01-01: 0.052 2023-01-01: 0.092 2024-01-01: 0.044 - 2025-01-01: 0.26 - 2026-01-01: 0.055 + 2025-01-01: 0.27 + 2026-01-01: 0.054 2027-01-01: 0.046 2028-01-01: 0.045 2029-01-01: 0.045 @@ -1204,10 +1238,14 @@ ofwat: unit: /1 label: Water bills growth reference: - - title: Ofwat (and custom projections) - href: https://www.ofwat.gov.uk/price-review/ - - title: Ofwat 2025-26 media statement + - title: Ofwat, "Ofwat responds to water sector average bill figures" (29 January 2026) + href: https://www.ofwat.gov.uk/ofwat-responds-to-water-sector-average-bill-figures/ + - title: Water UK, "Record investment as average household water bills rise £33 a year from April" (28 January 2026) + href: https://www.water.org.uk/news-views-publications/news/record-investment-average-household-water-bills-rise-ps33-year-april + - title: Ofwat, "Average bills 2025/26 press statement" (30 January 2025) href: https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/ + - title: CCW, "Breakdown of water companies' bill increases 2025-30" (Ofwat PR24 draft determinations) + href: https://www.ccw.org.uk/our-work/price-review/how-much-will-my-water-and-sewerage-bills-increase-by-2030/breakdown-of-water-companies-bill-increases-2025-30/ finance_ni: domestic_rates: description: Growth in the average Northern Ireland domestic rates bill (regional rate plus district rate), used to project the domestic_rates dataset input. diff --git a/policyengine_uk/tests/test_water_bills_growth.py b/policyengine_uk/tests/test_water_bills_growth.py new file mode 100644 index 0000000000..3cf6021b67 --- /dev/null +++ b/policyengine_uk/tests/test_water_bills_growth.py @@ -0,0 +1,25 @@ +"""The published Ofwat years of the water bills series match their sources. + +2025-26: Ofwat, "Ofwat responds to water sector average bill figures" +(29 January 2026) reports a 27% increase in 2025-26, replacing the 26% +forecast in its 30 January 2025 statement. +2026-27: Water UK (28 January 2026) puts the national average bill at GBP 639, +up GBP 33 or 5.4%. +""" + +import pytest + +from policyengine_uk.system import system + +WATER_BILLS = system.parameters.gov.economic_assumptions.yoy_growth.ofwat.water_bills +WATER_INDEX = system.parameters.gov.economic_assumptions.indices.ofwat.water_bills + + +@pytest.mark.parametrize("year, growth", [(2025, 0.27), (2026, 0.054)]) +def test_published_water_bill_growth(year, growth): + assert WATER_BILLS(f"{year}-01-01") == pytest.approx(growth) + + +def test_water_index_compounds_the_published_years(): + ratio = WATER_INDEX("2026-01-01") / WATER_INDEX("2024-01-01") + assert ratio == pytest.approx(1.27 * 1.054, rel=1e-3) From d50ee85a9664791d6c617135fed999418762f6a9 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 15:09:55 +0200 Subject: [PATCH 024/141] Add twenty planning and validation docs pages in one batch Combines the docs-only PRs #1712, #1714, #1718-#1732 and #1735-#1737, each re-checked against current main today. One release instead of twenty. Co-Authored-By: Claude Opus 5.5 --- .../1008-regional-childcare-plan.added.md | 1 + changelog.d/1069-pip-points-plan.added.md | 1 + changelog.d/1114.added.md | 1 + changelog.d/1209.added.md | 1 + changelog.d/1320.added.md | 1 + changelog.d/1354.added.md | 1 + changelog.d/1398.added.md | 1 + changelog.d/1621.added.md | 1 + changelog.d/1628.added.md | 1 + changelog.d/1692.added.md | 1 + changelog.d/435.added.md | 1 + changelog.d/466.added.md | 1 + changelog.d/502.added.md | 1 + changelog.d/636.added.md | 1 + changelog.d/669.added.md | 1 + changelog.d/672.added.md | 1 + changelog.d/704.added.md | 1 + changelog.d/806.added.md | 1 + changelog.d/818.added.md | 1 + changelog.d/842.added.md | 1 + .../capital-gains-distribution-plan.md | 133 ++++++++++++ docs/book/assumptions/mp30k-uk-switch-plan.md | 104 +++++++++ .../non-uk-benefit-receipt-plan.md | 130 +++++++++++ docs/book/assumptions/takeup-analysis-plan.md | 146 +++++++++++++ .../assumptions/uk-pipeline-alignment-plan.md | 173 +++++++++++++++ docs/book/policy/future-date-announcements.md | 141 ++++++++++++ docs/book/policy/indirect-tax-effects-plan.md | 136 ++++++++++++ .../programs/gov/cms/child-maintenance.md | 135 ++++++++++++ .../gov/dfe/regional-childcare-plan.md | 170 +++++++++++++++ .../gov/dwp/bereavement-support-plan.md | 162 ++++++++++++++ .../gov/dwp/cold-weather-payment-plan.md | 152 +++++++++++++ docs/book/programs/gov/dwp/pip-points-plan.md | 171 +++++++++++++++ docs/book/programs/gov/hmrc/firm-vat.md | 203 ++++++++++++++++++ .../programs/gov/hmrc/pension-tax-relief.md | 124 +++++++++++ .../gov/hmrc/pensions-comparison-stbm.md | 96 +++++++++ .../gov/ofgem/warm-home-discount-plan.md | 136 ++++++++++++ docs/book/validation/child-poverty-tcl.md | 120 +++++++++++ .../policyengine-py-household-impact.md | 119 ++++++++++ .../reform-impacts-dashboard-plan.md | 174 +++++++++++++++ docs/book/validation/ubuntu-vs-macos.md | 98 +++++++++ 40 files changed, 2843 insertions(+) create mode 100644 changelog.d/1008-regional-childcare-plan.added.md create mode 100644 changelog.d/1069-pip-points-plan.added.md create mode 100644 changelog.d/1114.added.md create mode 100644 changelog.d/1209.added.md create mode 100644 changelog.d/1320.added.md create mode 100644 changelog.d/1354.added.md create mode 100644 changelog.d/1398.added.md create mode 100644 changelog.d/1621.added.md create mode 100644 changelog.d/1628.added.md create mode 100644 changelog.d/1692.added.md create mode 100644 changelog.d/435.added.md create mode 100644 changelog.d/466.added.md create mode 100644 changelog.d/502.added.md create mode 100644 changelog.d/636.added.md create mode 100644 changelog.d/669.added.md create mode 100644 changelog.d/672.added.md create mode 100644 changelog.d/704.added.md create mode 100644 changelog.d/806.added.md create mode 100644 changelog.d/818.added.md create mode 100644 changelog.d/842.added.md create mode 100644 docs/book/assumptions/capital-gains-distribution-plan.md create mode 100644 docs/book/assumptions/mp30k-uk-switch-plan.md create mode 100644 docs/book/assumptions/non-uk-benefit-receipt-plan.md create mode 100644 docs/book/assumptions/takeup-analysis-plan.md create mode 100644 docs/book/assumptions/uk-pipeline-alignment-plan.md create mode 100644 docs/book/policy/future-date-announcements.md create mode 100644 docs/book/policy/indirect-tax-effects-plan.md create mode 100644 docs/book/programs/gov/cms/child-maintenance.md create mode 100644 docs/book/programs/gov/dfe/regional-childcare-plan.md create mode 100644 docs/book/programs/gov/dwp/bereavement-support-plan.md create mode 100644 docs/book/programs/gov/dwp/cold-weather-payment-plan.md create mode 100644 docs/book/programs/gov/dwp/pip-points-plan.md create mode 100644 docs/book/programs/gov/hmrc/firm-vat.md create mode 100644 docs/book/programs/gov/hmrc/pension-tax-relief.md create mode 100644 docs/book/programs/gov/hmrc/pensions-comparison-stbm.md create mode 100644 docs/book/programs/gov/ofgem/warm-home-discount-plan.md create mode 100644 docs/book/validation/child-poverty-tcl.md create mode 100644 docs/book/validation/policyengine-py-household-impact.md create mode 100644 docs/book/validation/reform-impacts-dashboard-plan.md create mode 100644 docs/book/validation/ubuntu-vs-macos.md diff --git a/changelog.d/1008-regional-childcare-plan.added.md b/changelog.d/1008-regional-childcare-plan.added.md new file mode 100644 index 0000000000..339f8f401f --- /dev/null +++ b/changelog.d/1008-regional-childcare-plan.added.md @@ -0,0 +1 @@ +Added a regional childcare planning page (docs/book/programs/gov/dfe/regional-childcare-plan.md) listing the Scottish, Welsh and Northern Ireland childcare schemes not yet modelled and how to add them. diff --git a/changelog.d/1069-pip-points-plan.added.md b/changelog.d/1069-pip-points-plan.added.md new file mode 100644 index 0000000000..759d59fe3d --- /dev/null +++ b/changelog.d/1069-pip-points-plan.added.md @@ -0,0 +1 @@ +Added a PIP point system planning page (docs/book/programs/gov/dwp/pip-points-plan.md) setting out the SI 2013/377 scoring rules and a phased route to modelling them, starting from #1661. diff --git a/changelog.d/1114.added.md b/changelog.d/1114.added.md new file mode 100644 index 0000000000..1d2a96c92b --- /dev/null +++ b/changelog.d/1114.added.md @@ -0,0 +1 @@ +Add a planning docs page (`docs/book/policy/indirect-tax-effects-plan.md`) on letting indirect tax revenue respond to direct tax reforms through a consumption elasticity, from a single household-level factor to per-category elasticities and a link to `consumer_incident_tax_revenue_change`. diff --git a/changelog.d/1209.added.md b/changelog.d/1209.added.md new file mode 100644 index 0000000000..cf2da497f8 --- /dev/null +++ b/changelog.d/1209.added.md @@ -0,0 +1 @@ +Documented the Ubuntu vs macOS reproducibility issue (#1209) at `docs/book/validation/ubuntu-vs-macos.md`: the import-time uprating cause, how it was resolved, and the October 2026 check that the parameter tree is byte-identical across macOS and Linux. diff --git a/changelog.d/1320.added.md b/changelog.d/1320.added.md new file mode 100644 index 0000000000..0c7cedbd66 --- /dev/null +++ b/changelog.d/1320.added.md @@ -0,0 +1 @@ +Add a planning docs page for firm-level VAT (`docs/book/programs/gov/hmrc/firm-vat.md`): a proposed `Firm` entity, the registration threshold and supply split, output, input and net VAT, the data needed for a synthetic firm panel, and open questions. diff --git a/changelog.d/1354.added.md b/changelog.d/1354.added.md new file mode 100644 index 0000000000..e21dfc8a1e --- /dev/null +++ b/changelog.d/1354.added.md @@ -0,0 +1 @@ +Added an effective take-up analysis plan (docs/book/assumptions/takeup-analysis-plan.md) listing each programme's take-up flag, reference parameter and current handling, where the data builds' seed rates live, and how to measure drift from them. diff --git a/changelog.d/1398.added.md b/changelog.d/1398.added.md new file mode 100644 index 0000000000..3119715c17 --- /dev/null +++ b/changelog.d/1398.added.md @@ -0,0 +1 @@ +Added a child poverty validation page (docs/book/validation/child-poverty-tcl.md) comparing the two-child limit removal on Microcosm UK 2024-25 (about 463,000 children lifted after housing costs, £3.26bn in 2029-30) with the DWP, Budget 2025 (OBR-certified) and Resolution Foundation estimates. diff --git a/changelog.d/1621.added.md b/changelog.d/1621.added.md new file mode 100644 index 0000000000..6c13eaceb8 --- /dev/null +++ b/changelog.d/1621.added.md @@ -0,0 +1 @@ +Added a UK pipeline alignment planning page (docs/book/assumptions/uk-pipeline-alignment-plan.md): the model-side take-up gap still open, and what Microcosm UK now covers (reported-anchored take-up, the SPI stage-2 fill). diff --git a/changelog.d/1628.added.md b/changelog.d/1628.added.md new file mode 100644 index 0000000000..9bf39b9e40 --- /dev/null +++ b/changelog.d/1628.added.md @@ -0,0 +1 @@ +Documented the policyengine.py household-impact zero-return diagnosis (docs/book/validation/policyengine-py-household-impact.md); the UK model returns the expected values, re-checked on 2.123.7. diff --git a/changelog.d/1692.added.md b/changelog.d/1692.added.md new file mode 100644 index 0000000000..1816c55e99 --- /dev/null +++ b/changelog.d/1692.added.md @@ -0,0 +1 @@ +Add a planning docs page (`docs/book/assumptions/mp30k-uk-switch-plan.md`) setting out the gates and rollback path for changing the default dataset. It was written for Microplex UK and now applies to its successor, Microcosm UK. diff --git a/changelog.d/435.added.md b/changelog.d/435.added.md new file mode 100644 index 0000000000..4ac12a8377 --- /dev/null +++ b/changelog.d/435.added.md @@ -0,0 +1 @@ +Added a plan for modelling Cold Weather Payment and the Scottish Winter Heating Payment (#435) at `docs/book/programs/gov/dwp/cold-weather-payment-plan.md`. diff --git a/changelog.d/466.added.md b/changelog.d/466.added.md new file mode 100644 index 0000000000..5bf393a6b5 --- /dev/null +++ b/changelog.d/466.added.md @@ -0,0 +1 @@ +Added a plan for a rule-based Bereavement Support Payment and historical Widowed Parent's Allowance (#466, #465) at `docs/book/programs/gov/dwp/bereavement-support-plan.md`. diff --git a/changelog.d/502.added.md b/changelog.d/502.added.md new file mode 100644 index 0000000000..6f0751e936 --- /dev/null +++ b/changelog.d/502.added.md @@ -0,0 +1 @@ +Added a plan for modelling the Warm Home Discount (#502) at `docs/book/programs/gov/ofgem/warm-home-discount-plan.md`, reflecting the benefit-based eligibility in England and Wales from winter 2025-26 and the separate Scottish scheme. diff --git a/changelog.d/636.added.md b/changelog.d/636.added.md new file mode 100644 index 0000000000..aefcb63f8e --- /dev/null +++ b/changelog.d/636.added.md @@ -0,0 +1 @@ +Added a page (docs/book/policy/future-date-announcements.md) on encoding announced policy changes as date-keyed parameter values, and how those dates become model-year values. diff --git a/changelog.d/669.added.md b/changelog.d/669.added.md new file mode 100644 index 0000000000..be24e5dbce --- /dev/null +++ b/changelog.d/669.added.md @@ -0,0 +1 @@ +Add a planning docs page for child maintenance (`docs/book/programs/gov/cms/child-maintenance.md`): how `maintenance_income` and `maintenance_expenses` are treated today, the gap for spousal maintenance in Universal Credit, and a route to modelling the Child Maintenance Service calculation. diff --git a/changelog.d/672.added.md b/changelog.d/672.added.md new file mode 100644 index 0000000000..2e0a22b781 --- /dev/null +++ b/changelog.d/672.added.md @@ -0,0 +1 @@ +Add a documentation page (`docs/book/programs/gov/hmrc/pensions-comparison-stbm.md`) comparing PolicyEngine UK's treatment of pension contributions in income tax with the Scottish Tax-Benefit Model's source code, including a gap both share: contributions do not reduce adjusted net income for the personal allowance taper. diff --git a/changelog.d/704.added.md b/changelog.d/704.added.md new file mode 100644 index 0000000000..6cd2db94ea --- /dev/null +++ b/changelog.d/704.added.md @@ -0,0 +1 @@ +Add a documentation page at `docs/book/programs/gov/hmrc/pension-tax-relief.md` explaining how PolicyEngine UK models personal pension tax relief (full marginal-rate reduction in taxable income via `pension_contributions_relief`, with separate annual-allowance excess charge via `personal_pension_contributions_tax`), and clarifying that the model correctly reports the *total* relief (both relief-at-source + higher-rate top-up combined) for the scenarios reported in #704. diff --git a/changelog.d/806.added.md b/changelog.d/806.added.md new file mode 100644 index 0000000000..3a5e0d597d --- /dev/null +++ b/changelog.d/806.added.md @@ -0,0 +1 @@ +Added a plan for a reform impacts dashboard (#806) at `docs/book/validation/reform-impacts-dashboard-plan.md`, tracking the regression fixtures in `reforms_config.yaml` over model and dataset versions. diff --git a/changelog.d/818.added.md b/changelog.d/818.added.md new file mode 100644 index 0000000000..ee8c0776c2 --- /dev/null +++ b/changelog.d/818.added.md @@ -0,0 +1 @@ +Documented how capital gains are modelled and imputed (#818, #817) at `docs/book/assumptions/capital-gains-distribution-plan.md`, including the Microcosm UK stages that now fit gains jointly to HMRC Table 3 and what is left to do. diff --git a/changelog.d/842.added.md b/changelog.d/842.added.md new file mode 100644 index 0000000000..a6930079a4 --- /dev/null +++ b/changelog.d/842.added.md @@ -0,0 +1 @@ +Add a planning docs page (`docs/book/assumptions/non-uk-benefit-receipt-plan.md`) on benefit spending that does not reach UK private households (payments abroad, institutions, fraud and error) and how calibration targets should exclude it. diff --git a/docs/book/assumptions/capital-gains-distribution-plan.md b/docs/book/assumptions/capital-gains-distribution-plan.md new file mode 100644 index 0000000000..5e47427100 --- /dev/null +++ b/docs/book/assumptions/capital-gains-distribution-plan.md @@ -0,0 +1,133 @@ +# Capital gains distribution + +```{note} +**Planning page, updated October 2026.** Tracks +[#818](https://github.com/PolicyEngine/policyengine-uk/issues/818) +(model gains jointly across income groups) and +[#817](https://github.com/PolicyEngine/policyengine-uk/issues/817) +(don't impute gains to households with no wealth). The imputation lives +in the data build, not in this repo. Most of what this page proposed +in May 2026 has since been built in +[Microcosm UK](https://github.com/PolicyEngine/microcosm), which builds +the replacement for the Enhanced FRS. The certified UK default dataset is +still the Enhanced FRS from +[policyengine-uk-data](https://github.com/PolicyEngine/policyengine-uk-data) +(release 1.58.0). What remains is listed at the end of this page. +``` + +## How the model uses capital gains + +`capital_gains_before_response` is a person-level input, uprated by OBR +per-capita GDP growth. `capital_gains_tax` charges it at the main CGT +rates, with three components charged at their own rates (all added by +[#1861](https://github.com/PolicyEngine/policyengine-uk/pull/1861)): + +- `capital_gains_badr`: gains qualifying for Business Asset Disposal + Relief or Investors' Relief, at the relief rate up to the lifetime + limit; +- `capital_gains_residential_property`: gains on UK residential + property, at the residential rates; +- `capital_gains_carried_interest`: carried interest, at the carried + interest rates. + +Each is a component of the total, not an addition to it. + +Behavioural responses: `capital_gains_behavioural_response` changes +realisations using an elasticity with respect to the retention rate +(`relative_capital_gains_retention_rate_change`) or the marginal tax +rate. Gains use `gov.simulation.capital_gains_responses.elasticity`, +which is 0 by default, so there is no response unless a user sets it. +When `gov.simulation.capital_gains_responses.separate_badr_elasticity` +is true (false by default), gains qualifying for BADR respond at +`badr_elasticity` instead (1.4, the value the OBR used for these gains +in its January 2025 costing), through `capital_gains_badr_elasticity`. + +## How the gains are imputed + +### Enhanced FRS: one distribution per income band + +The Enhanced FRS (policyengine-uk-data, the certified default) fits a +spline to the Advani and Summers gains percentiles within each income +band and draws each household's gains from its band. The spline alone +cannot produce gains above about £2m, so policyengine-uk-data main (not +yet in a release) also stacks 30 donor households per HMRC +size-of-gain band and calibrates to HMRC counts and amounts of gains by +band. #818 pointed out the problems with the +band-by-band fit: + +- each band's upper tail was fitted on few observations, so the tails + were noisy and the joint distribution of income and gains had breaks + at band boundaries; +- two people in the same band drew from the same distribution however + different their incomes; +- gains depended on income only, not on wealth, so they could be + imputed to households with no assets (#817). + +### Microcosm UK + +Microcosm builds gains in six stages (see the UK +[sources specification](https://github.com/PolicyEngine/microcosm/blob/main/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml)): + +1. **Support split** (`cgt_support_split`). Within each income band of + HMRC Capital Gains Tax statistics Table 3, the households with the + most investable wealth (financial wealth, business wealth and + property other than the main home) are split into lighter copies, so + that there are enough rows to hold the largest gains. +2. **Who has gains** (`cgt_incidence_clone`). The set and order of + gainers come from the incidence distribution in Advani and Summers + (2020), *Capital Gains and UK Inequality* (CAGE Working Paper 465). +3. **How much** (`hmrc_cgt_gains_spine`). Amounts are redrawn so that + gains match HMRC Table 3 for 2024-25, which counts taxpayers and + gains **jointly by size of gain and taxable income**, conditioned on + Tables 1, 2.1a, 5 and 6. +4. **Weight anchor** (`cgt_incidence_anchor`). Clones below the annual + exempt amount carry more weighted people than HMRC publishes, so the + excess weight is moved back to each clone's paired original. +5. **Residential property** (`cgt_residential_split`). Gains on UK + residential property are fitted to Table 8 (2024-25 residential + property disposals) by splitting households into weighted copies, + which fills `capital_gains_residential_property`. +6. **What kind** (`hmrc_cgt_asset_type_spine`). Of the remaining gains, + those qualifying for BADR or Investors' Relief are fitted to Table + 4.1, which fills `capital_gains_badr`, and the rest are given asset + types from Table 7 (gains by asset type, 2023-24). + +All the HMRC tables come from the Capital Gains Tax statistics July +2026 release, through Microcosm's pinned Chronicle feed. + +Against #818: gains are now fitted to the published joint distribution +of gain size and taxable income, not band by band. Against #817: +the largest gains are placed on the wealthiest households in each +income band. Microcosm's release checks also include a projection check +on people just below the annual exempt amount +(`uk_cgt_projection_entrants` in `gates.json`), so that +uprating does not push implausible numbers of them into paying CGT in +later years. + +## What is left + +- **A zero-wealth test.** Add a test, here or in Microcosm, that no + positive gains sit on households with zero investable wealth. Neither + repo asserts this directly yet, so #817 should stay open until it + does. +- **Close #818** once a Microcosm UK dataset is the certified default + and its diagnostics confirm the Table 3 fit; link them from the issue. +- **Elasticities by group.** Responses use one main elasticity and one + BADR elasticity. With the joint distribution in place, an elasticity + that varies by income or size of gain becomes possible, at the cost + of more parameters to justify. +- **Wealth source.** Investable wealth comes from Microcosm's Wealth + and Assets Survey imputation. Its quality limits how well #817 can be + met. + +The quantile regression forest approach proposed here in May is no +longer needed for this. + +## References + +- Issues: [#818](https://github.com/PolicyEngine/policyengine-uk/issues/818), [#817](https://github.com/PolicyEngine/policyengine-uk/issues/817). +- CGT components: [#1861](https://github.com/PolicyEngine/policyengine-uk/pull/1861). +- HMRC, [Capital Gains Tax statistics](https://www.gov.uk/government/statistics/capital-gains-tax-statistics). +- Advani, A. and Summers, A. (2020), *Capital Gains and UK Inequality*, CAGE Working Paper 465. +- Microcosm UK [sources specification](https://github.com/PolicyEngine/microcosm/blob/main/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml) (stages `cgt_support_split`, `cgt_incidence_clone`, `hmrc_cgt_gains_spine`, `cgt_incidence_anchor`, `cgt_residential_split`, `hmrc_cgt_asset_type_spine`). +- OBR (2025), [Costing of changes to the main, BADR and IR rates of capital gains tax](https://obr.uk/docs/dlm_uploads/CGT-supplementary-release-Jan-2025.pdf). diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md new file mode 100644 index 0000000000..39f9d64841 --- /dev/null +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -0,0 +1,104 @@ +# Switching the default dataset: gates and rollback + +```{note} +**Planning page.** [#1692](https://github.com/PolicyEngine/policyengine-uk/issues/1692) +asked for a switch and rollback path from the Enhanced FRS (eFRS) to Microplex UK +(`mp-30k-uk`). The `microplex-uk` repository has since been archived, and its +successor is [Microcosm UK](https://github.com/PolicyEngine/microcosm), whose +UK national line publishes `microcosm_uk_2024_25.h5`. This page applies the +same switch and rollback path to Microcosm UK. +``` + +## Current state + +PolicyEngine UK has no built-in default dataset. `Simulation()` uses, in order: + +1. an explicit `dataset=` argument; +2. the `POLICYENGINE_UK_DEFAULT_DATASET` environment variable + (`DEFAULT_DATASET_ENV_VAR` in `tax_benefit_system.py`); +3. otherwise, unless an inline `situation` is passed, it raises a `ValueError` + (`get_default_dataset_url` in `simulation.py`). + +The test suite sets the variable to the private +`hf://policyengine/policyengine-uk-data-private/enhanced_frs_2023_24.h5@1.40.3` +when a Hugging Face token is available (`policyengine_uk/tests/conftest.py`). +So the eFRS is still the dataset the model is tested against, and the +default in downstream tools such as policyengine.py. + +Direct microsimulation through `Microsimulation()` in this package is +deprecated in favour of [policyengine.py](https://github.com/PolicyEngine/policyengine.py), +whose managed bundle pins certified datasets (see the repository README). The +default most users meet is therefore the one that bundle pins, so a switch has +to be made there as well as in this repository's tests. + +## What the switch needs + +### Loader + +- `UKMultiYearDataset` and `UKSingleYearDataset` + (`policyengine_uk/data/dataset_schema.py`) are the containers both datasets + load into. A Microcosm file must use the same table and column conventions, + because `_pre_encode_enum_columns` (in `simulation.py`) walks each year's + tables. +- The dataset's `years` drive uprating. Microcosm's base year is 2024-25, + one year later than the eFRS file used in tests, so uprating paths from the + earlier base year need checking. +- Enum columns must use the labels in this repo's variable definitions. + Unknown labels do not encode correctly. + +### Variables + +- `policyengine_uk/variables/input/` is the data contract. A switch should + report the input columns present in one dataset but not the other, and + decide for each whether a missing column is acceptable. +- Take-up and other reported-versus-simulated inputs have to come from the + new data, not be carried over from the eFRS. + +### Calibration + +- Microcosm calibrates its own weights against its target register, and + certifies each release through its own checks. The model side should + compare results against those targets, not re-weight. + +## Switch criteria + +A documented gate must pass before any environment's default changes: + +1. **Aggregates:** headline totals (income tax, NICs, UC, Child Benefit, + State Pension) on both datasets for the same year, each set against OBR + outturn, with any difference of more than £1bn or 2% (whichever is wider) + explained. +2. **Distribution:** decile mean net income and HBAI poverty rates (before + and after housing costs) within 2 percentage points, or explained. +3. **Reforms:** at least three standard reforms (for example +1p on the basic + rate, +£5 a week on the UC standard allowance, abolishing the two-child + limit) give the same sign and are within 5% of each other. +4. **Tests:** the suite passes with the new default, including + `test_latest_data_smoke.py`, `test_deterministic_variables.py` and + `test_no_economic_assumptions.py`. +5. **Documentation:** the `assumptions/` section records the switch and any + remaining aggregate gaps. + +## Rollback path + +1. Dataset choice stays a runtime setting: no code in this repo depends on + which dataset is loaded. +2. While both are in use, CI runs the dataset-dependent tests on both. +3. The default is set per environment through + `POLICYENGINE_UK_DEFAULT_DATASET`, with the eFRS file kept available. +4. Rolling back is changing that variable and redeploying, with no code + change here. This assumes the eFRS file stays published while the switch + beds in. + +## Acceptance + +PolicyEngine UK switches its default only after the gates above pass, with a +rollback that needs no code change in this repo. Until then either dataset can +be used by passing `dataset=` or setting the environment variable. + +## References + +- Tracking issue: [#1692](https://github.com/PolicyEngine/policyengine-uk/issues/1692) (still open). Its umbrella discussion is in the archived [`microplex-uk`](https://github.com/PolicyEngine/microplex-uk/discussions/2) repository. +- Related: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621) (aligning the UK model and data pipeline). +- Microcosm UK: [repository](https://github.com/PolicyEngine/microcosm). +- Dataset loading: `policyengine_uk/simulation.py`, `policyengine_uk/data/dataset_schema.py`. diff --git a/docs/book/assumptions/non-uk-benefit-receipt-plan.md b/docs/book/assumptions/non-uk-benefit-receipt-plan.md new file mode 100644 index 0000000000..928177417f --- /dev/null +++ b/docs/book/assumptions/non-uk-benefit-receipt-plan.md @@ -0,0 +1,130 @@ +# Benefit expenditure paid outside UK private households (planned) + +```{note} +**Planning page.** The data behind PolicyEngine UK is calibrated so that +household benefit totals match published **DWP/HMRC/OBR totals**. +Some of that outturn doesn't go to **UK private households**. It +goes to people living abroad (exported pensions), people in +institutional accommodation outside the FRS sampling frame (care homes, +hostels), and a small administrative-leakage residual. This page +sets out the proposed treatment, tracked in +[#842](https://github.com/PolicyEngine/policyengine-uk/issues/842) (still +open, October 2026). Calibration targets are now set in +[Microcosm UK](https://github.com/PolicyEngine/microcosm), so most of the +work below belongs there. +``` + +## Why this matters + +Calibrating FRS household weights so that simulated `state_pension` +matches a national spending total assumes every pound of that total lands +on a household the FRS samples. It doesn't: + +- **State Pension paid abroad**: pensioners living overseas receive the + State Pension they built up. DWP's benefit expenditure tables report + the amount paid to recipients abroad, and Stat-Xplore gives the + caseload by country. None of these people are in the FRS. +- **Disability Living Allowance and PIP paid abroad**: limited cases + under reciprocal agreements (EU and EFTA countries and a handful of + others). +- **Institutional residents**: care-home residents receive most + benefits but the FRS doesn't sample care homes. Their entitlements + feed into the DWP outturn but not into PolicyEngine's `state_pension` + totals. +- **Administrative leakage**: fraud, error and advance payments that + appear in DWP cashflow but never reach an entitled person. + +Where a target includes these groups, calibration pushes their spending +onto FRS households and **overstates** what UK private households receive. + +**Already done for State Pension.** Microcosm no longer binds the OBR State +Pension line. State Pension is now bound to resident figures from DWP +Stat-Xplore (Great Britain) and the Northern Ireland Department for +Communities, and the OBR line and DWP's "paid outside UK" line are kept as +diagnostics. The plan is +[microcosm#1069](https://github.com/PolicyEngine/microcosm/issues/1069) and +the change landed in +[microcosm#1084](https://github.com/PolicyEngine/microcosm/pull/1084) +(merged 2 October 2026). The same question is open for other programmes. + +## Scope + +### Phase 1: quantify the gap by programme + +For each benefit programme the calibration targets, identify the share +of the published total that goes outside UK private households. DWP and HMRC +publish enough data to assemble a first-pass table: + +| Programme | UK private households | Overseas | Institutional | Admin / error | +|-----------|----------------------|----------|---------------|---------------| +| State Pension | published | DWP overseas tables | -- | -- | +| Pension Credit | published | small | minimal | minor | +| Universal Credit | published | minimal | small | F&E published | +| Housing Benefit | published | -- | care-home component | F&E published | +| Child Benefit | published | EU-treaty residue | -- | -- | +| Disability Living Allowance / PIP | published | reciprocal residue | -- | -- | +| Attendance Allowance | published | -- | care-home residue | -- | +| Winter Fuel Payment | published | overseas eligible cohort | -- | -- | + +Sources: DWP *Benefit expenditure and caseload tables* and Stat-Xplore +(payments abroad), the DWP *Fraud and Error in the Benefit System* annual +release, and programme-specific breakdowns. + +### Phase 2: subtract from calibration targets + +Once the gap is quantified per programme, each **calibration target** +should be the **UK-private-household part** of the total, not the full +total. Microcosm's State Pension change does this by binding a resident +series instead. Where no resident series exists, the target can carry an +explicit private-household share (0–1, default 1) with a cited source for +each override. + +This avoids the overweighting bias without trying to synthesise +out-of-scope households. + +### Phase 3: explicit out-of-scope correction (optional) + +For headline aggregates that need to match the **full** outturn (e.g. +fiscal cost of a reform), maintain an out-of-scope additive correction +per programme rather than synthesising fake households. This is +preferable because: + +- Distributional analyses already get the right answer at Phase 2 (UK + private households are correctly weighted to their own outturn). +- Synthesising overseas / institutional households would require strong + assumptions about their characteristics that aren't validated against + any micro-data source. + +The additive correction would live in the data pipeline and appear in +PolicyEngine UK as an explicit non-household spending line. + +## Implementation outline + +This is mainly a **data-side** change in Microcosm UK's target +register. (`policyengine_uk/programs.yaml` is metadata on model coverage, +not calibration targets, so it is not the place for these shares.) The +changes in this repo are small: + +- a non-household spending line for the Phase 3 correction, if adopted; +- documentation linking the affected benefit variables to this page. + +## Open questions + +- Should the `private_household_share` be year-varying (e.g. overseas + State Pension share rose post-EU exit) or held flat? +- For Universal Credit, where the FRS *does* sample some + institutionalised people (in supported accommodation), what's the + correct private-household-share value: strictly < 1, or 1 minus a + smaller residual? +- For the household-calculator (single-household) interface, the + private-household-share correction shouldn't apply, because the user + represents their own household. Confirm the correction is only on the + microsim weighted aggregates. + +## References + +- DWP, [Benefit expenditure and caseload tables](https://www.gov.uk/government/collections/benefit-expenditure-and-caseload-tables): spending by benefit, including payments abroad. +- DWP, [Fraud and Error in the Benefit System](https://www.gov.uk/government/collections/fraud-and-error-in-the-benefit-system): administrative leakage estimates. +- DWP, [Stat-Xplore](https://stat-xplore.dwp.gov.uk/): caseloads by benefit, including State Pension recipients by country of residence. +- HMRC, [Tax credits and Child Benefit statistics](https://www.gov.uk/government/collections/personal-tax-credits-statistics): EU-treaty Child Benefit residue. +- Issue: [#842](https://github.com/PolicyEngine/policyengine-uk/issues/842). Related: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621) (UK pipeline alignment) and [microcosm#1069](https://github.com/PolicyEngine/microcosm/issues/1069) (State Pension on resident figures). diff --git a/docs/book/assumptions/takeup-analysis-plan.md b/docs/book/assumptions/takeup-analysis-plan.md new file mode 100644 index 0000000000..d4836b0de7 --- /dev/null +++ b/docs/book/assumptions/takeup-analysis-plan.md @@ -0,0 +1,146 @@ +# Effective take-up rate analysis plan + +```{note} +**Planning page.** Tracks +[#1354](https://github.com/PolicyEngine/policyengine-uk/issues/1354), +which asks for a comprehensive analysis of how the data processing +pipeline (reweighting, SPI integration) changes effective take-up +rates away from the seed values. This page documents the methodology +and per-programme target list. +``` + +```{admonition} Status, October 2026 +The analysis below has not been run yet. Take-up flags are drawn in the +data build, not in this repository. The certified UK default dataset is +still the Enhanced FRS built by +[`policyengine-uk-data`](https://github.com/PolicyEngine/policyengine-uk-data) +(release 1.58.0). [Microcosm UK](https://github.com/PolicyEngine/microcosm) +builds the replacement national dataset with the same take-up design. +Both draw Universal Credit, Pension Credit and Child Benefit take-up +anchored on reported receipt, so the analysis should run on whichever +dataset is the certified default at the time. +``` + +## Why this matters + +PolicyEngine UK does not compute benefit take-up from scratch. The data +build **seeds** stochastic take-up flags (`would_claim_*`) per programme +from published target rates, then runs them through: + +1. **Reported anchoring.** Universal Credit, Pension Credit and Child + Benefit flags are inputs, drawn so that units reporting receipt + claim. Housing Benefit, Income Support, the tax credits and Council + Tax Reduction instead derive their flags at runtime from reported + receipt (or `claims_all_entitled_benefits`); see + [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +2. **Reweighting** to match aggregate caseload and expenditure + targets. +3. **SPI integration** for income-tax-relevant variables (which can + shift the income distribution and therefore who falls into each + benefit's eligible population). + +By the end of the pipeline the *effective* take-up rate, the share of +the eligible population that ends up with `would_claim_X == True`, can +diverge from the seed rate by a few percentage points. The seeded rates +themselves were calibrated against pre-reweighted aggregates, so the +real interest is how the post-pipeline shares stack up against +published outturn share figures. + +## Where the seed rates live + +The take-up rate parameters in this repository (for example +`gov.dwp.universal_credit.takeup_rate` and `gov.dwp.housing_benefit.takeup`) +are reference values: no formula reads them. The rates the data build +draws from are in `policyengine_uk_data/parameters/take_up/` in +policyengine-uk-data and in `build/uk/take_up_contract.json` in +Microcosm. The analysis should read the seed rate from the data build +that produced the dataset, and check the matching parameter here is in +sync. + +## Programmes to cover + +Per #1354, with current PolicyEngine UK take-up handling: + +| Programme | Variable | Take-up flag | Reference parameter here | Handling | +|-----------|----------|--------------|--------------------------|----------| +| Universal Credit | `universal_credit` | `would_claim_uc` (input) | `gov.dwp.universal_credit.takeup_rate` | Drawn in the data build, anchored on reported receipt | +| UC childcare element | `uc_childcare_element_claimed` | `would_claim_uc_childcare` (input) | – | Drawn in Microcosm at single and couple rates; the Enhanced FRS does not set it, so it defaults to true | +| Pension Credit | `pension_credit` | `would_claim_pc` (input) | `gov.dwp.pension_credit.takeup` | Drawn in the data build, anchored on reported receipt | +| Child Benefit | `child_benefit` | `would_claim_child_benefit` (input) | `gov.hmrc.child_benefit.takeup.overall` and `.by_age` | Drawn in the data build, anchored on reported receipt | +| Housing Benefit | `housing_benefit` | `would_claim_housing_benefit` (formula) | `gov.dwp.housing_benefit.takeup` | Follows reported receipt (#1621) | +| Income Support | `income_support` | `would_claim_IS` (formula) | `gov.dwp.income_support.takeup` | Follows reported receipt (#1621) | +| Child Tax Credit | `child_tax_credit` | `would_claim_CTC` (formula) | `gov.dwp.tax_credits.child_tax_credit.takeup` | Follows reported receipt; scheme ended 2025-04-06 | +| Working Tax Credit | `working_tax_credit` | `would_claim_WTC` (formula) | `gov.dwp.tax_credits.working_tax_credit.takeup` | Follows reported receipt; scheme ended 2025-04-06 | +| Council Tax Reduction | `council_tax_reduction` | `would_claim_council_tax_reduction` (formula) | – | Local schemes simulated; take-up follows reported receipt (#1669 open) | +| Tax-Free Childcare | `tax_free_childcare` | `would_claim_tfc` (input) | – | Drawn in the data build at a flat rate | +| Universal childcare entitlement | `universal_childcare_entitlement` | `would_claim_universal_childcare` (input) | – | Drawn in the data build at a flat rate | +| Extended childcare entitlement | `extended_childcare_entitlement` | `would_claim_extended_childcare` (input) | – | Drawn in the data build at a flat rate | +| Targeted childcare entitlement | `targeted_childcare_entitlement` | `would_claim_targeted_childcare` (input) | – | Drawn in the data build at a flat rate | +| Marriage Allowance | `marriage_allowance` | `would_claim_marriage_allowance` (input) | `gov.hmrc.income_tax.allowances.marriage_allowance.takeup_rate` (0.5, the post-2019 steady state) | Drawn in the data build at a flat rate | +| Scottish Child Payment | `scottish_child_payment` | `would_claim_scp` (input) | `gov.social_security_scotland.scottish_child_payment.takeup_rate` (under 6, 6 and over) | Drawn in the data build by age | +| Carer's Allowance | `carers_allowance` | `would_claim_carers_allowance` (input) | – | Set from reported receipt in Microcosm; the Enhanced FRS does not set it, so it defaults to true | +| 16 to 19 Bursary Fund | `bursary_fund_16_to_19` | `would_claim_bursary_fund_16_to_19` (input) | – | Defaults to true | +| Student support grants | `adult_dependants_grant`, `childcare_grant`, `parents_learning_allowance`, `travel_grant`, `disabled_students_allowance` | `would_claim_adult_dependants_grant`, `would_claim_childcare_grant`, `would_claim_parents_learning_allowance`, `would_claim_travel_grant`, `would_claim_disabled_students_allowance` (inputs) | – | Default to true | +| Care to Learn | `care_to_learn` | `would_claim_care_to_learn` (input) | – | Defaults to true | +| State Pension | `state_pension` | n/a (effectively universal) | – | See [state-pension.md](../programs/gov/dwp/state-pension.md) | +| PIP / DLA / AA / SDA | various | none | – | Category inputs set from reported receipt; see [disability-legacy-benefits.md](../programs/gov/dwp/disability-legacy-benefits.md) | + +## Methodology + +For each programme above, the analysis should produce: + +1. **Seed take-up rate:** the rate the data build drew from for the + analysis year, plus the source (HMRC, DWP or an academic study) and + vintage. +2. **Effective take-up rate post-pipeline:** counted from the built + dataset as `sum(would_claim_X * weight) / sum(eligible_X * weight)`, + where `eligible_X` is computed from the runtime formulas. +3. **Published outturn share** for the same year: DWP and HMRC + statistical publications giving claimant counts and either eligible + population counts (where DWP estimates them) or a published take-up + rate. +4. **Pipeline drift:** `effective − seed` per programme, with a sign + convention so positive means more claimants than seeded. + +The published reference rates change roughly annually. The analysis +should be repeatable, ideally as a notebook under +[`docs/book/validation/`](../validation/) so future builds can re-run +it and update the gaps table. + +## Output + +A new validation notebook +`docs/book/validation/take-up-rates.ipynb` should: + +- pull each programme's seed take-up parameter, +- compute the effective rate against the built dataset, +- compare to the most recent published share (DWP take-up tables for + income-related benefits, HMRC Tax-Free Childcare and tax credits + statistics for the rest), +- flag drifts of more than ±2 percentage points for follow-up. + +The first run is the deliverable for #1354. After that the notebook +becomes a part of the validation page and gets re-run on each EFO / +data refresh. + +## Related work + +- **Reported-anchored take-up** is in both data builds for UC, Pension + Credit and Child Benefit. The programmes whose flags still follow + reported receipt at runtime are the most likely source of drift + between seed and effective rates. +- The **`would_claim_*` input-only conversion** (also in #1621) would + give the analysis a cleaner ladder: with all of `would_claim_*` as + inputs, the effective rate is exactly the share assigned by the + data build's take-up stage, and pipeline drift becomes a pure + reweighting question. + +## References + +- Issue: [#1354](https://github.com/PolicyEngine/policyengine-uk/issues/1354). +- Initial analysis (UC and CTC): +- DWP, [Income-related benefits: estimates of take-up](https://www.gov.uk/government/collections/income-related-benefits-estimates-of-take-up): the main reference rates for Housing Benefit, Income Support, Pension Credit and Universal Credit. +- HMRC, [Personal tax credits statistics](https://www.gov.uk/government/collections/personal-tax-credits-statistics). +- HMRC, [Child Benefit statistics](https://www.gov.uk/government/collections/child-benefit-statistics) and Marriage Allowance statistics. +- Pipeline alignment: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +- Cross-links: [`state-pension.md`](../programs/gov/dwp/state-pension.md), [`disability-legacy-benefits.md`](../programs/gov/dwp/disability-legacy-benefits.md), [`tax-credits.md`](../programs/gov/dwp/tax-credits.md). diff --git a/docs/book/assumptions/uk-pipeline-alignment-plan.md b/docs/book/assumptions/uk-pipeline-alignment-plan.md new file mode 100644 index 0000000000..34def30814 --- /dev/null +++ b/docs/book/assumptions/uk-pipeline-alignment-plan.md @@ -0,0 +1,173 @@ +# UK model/data pipeline alignment plan + +```{note} +**Planning page** setting out the gaps between the UK model and data +pipeline and the US `policyengine-us` / `policyengine-us-data` patterns, +plus a sequence of scoped PRs that would close them. Tracked in +[#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +Most items here are **cross-repo**: they touch the data pipeline rather +than this repo, but the model-side contract is documented here so the +receiving side has a stable target. +``` + +```{admonition} Status, October 2026 +The target data pipeline is now +[Microcosm UK](https://github.com/PolicyEngine/microcosm), which replaced +the Enhanced FRS build in `policyengine-uk-data`; its UK national line is +built on the Family Resources Survey 2024-25 (`microcosm_uk_2024_25`). +Gaps 2 and 3 below, and PR 4 of the sequence, are implemented there; +gap 1 is still open on the model side. The aggregate residuals are the +May 2026 Enhanced FRS measurements and have not been re-measured on the +Microcosm release. +``` + +## Architectural gaps vs US + +### 1. `_reported` consulted at runtime in some `would_claim_*` formulas + +**US pattern**: `_reported` columns are consulted **once, in the +data-build**, to anchor stochastic take-up flags (reported recipient → +takeup = True with certainty; rest filled probabilistically to a target +rate). Runtime formulas only read the pre-computed +`takes_up_X_if_eligible` flag, so reforms that change eligibility flow +through cleanly to take-up. + +**UK pattern (partial)**: some UK `would_claim_*` variables already +match: `would_claim_uc` and `would_claim_pc` are input-only with +`default_value = True`, populated by the data build (now Microcosm +UK's `frs_take_up` stage). But several still derive from +`_reported` at runtime: + +- `would_claim_housing_benefit` +- `would_claim_IS` +- `would_claim_WTC` +- `would_claim_CTC` + +The runtime pattern is `claims_all_entitled_benefits | (foo_reported > 0)`, +which means a reform expanding eligibility only reaches existing +`_reported > 0` claimants. Non-claimants who newly qualify are +invisible unless the user manually sets +`claims_all_entitled_benefits = True` everywhere. + +**Fix**: convert each to input-only `default_value = True` (matching +`would_claim_uc`), and add each to Microcosm UK's reported-anchored +take-up stages. Tax credits stopped paying ongoing awards on 5 April +2025, and `gov.dwp.tax_credits.active` is false from 2025-04-06 (see +[tax-credits.md](../programs/gov/dwp/tax-credits.md)), so the WTC and CTC +fix matters only for simulations of earlier years. + +### 2. Take-up assignment ignores reported data + +**Done in Microcosm UK.** Its `frs_take_up`, `pension_credit_take_up` +and `child_benefit_take_up` stages keep every benefit unit that reports +receipt as a claimant and draw the rest to the target rate +([`source_stages.json` at `75167a6`](https://github.com/PolicyEngine/microcosm/blob/75167a68/packages/microcosm-build/src/microcosm/build/uk/source_stages.json)). +The description below is the Enhanced FRS behaviour this replaced. + +The Enhanced FRS take-up assignment was a pure random draw: + +```python +pe_benunit["would_claim_uc"] = generator.random(len(pe_benunit)) < universal_credit_rate +``` + +This discards information: respondents who reported receiving UC +clearly took it up, and should have `would_claim_uc = True` with +certainty. + +**Fix**: port `policyengine_us_data/utils/takeup.py::assign_takeup_with_reported_anchors` +into `policyengine-uk-data` and apply it to each take-up variable where +a `_reported` column exists. This tightens per-variable calibration +without changing the target rates. + +### 3. Second-stage imputation gap on SPI donors + +**Done in Microcosm UK.** `hmrc_spi_income_spine` trains an FRS-only +second stage on the new SPI incomes (its outputs include `gift_aid` and +`charitable_investment_gifts`), `spi_housing_shell` redraws tenure, +dwelling and council tax band for the SPI households, and the wealth, +spending and VAT stages now run after the SPI block, so they are drawn +for the new incomes rather than inherited from the FRS parent. The +description below is the Enhanced FRS behaviour this replaced. + +`policyengine_uk_data/datasets/imputations/income.py::impute_income` +trains a QRF that replaces only six income variables on SPI donor +rows; everything else (rent, wealth, pension contributions, gift aid, +benefits-reported flags) stays as whatever middle-income FRS donor +happened to be sampled. So a synthetic SPI donor ending up with £2M +imputed self-employment income still carries an unchanged middle-FRS- +donor's savings balance and zero gift aid. + +The US equivalent ([`policyengine-us-data#589`](https://github.com/PolicyEngine/policyengine-us-data/pull/589)) +fixed this by adding a second-stage QRF: train on CPS with predictors = +demographics + newly-imputed income vars, outputs = the ~60 CPS-only +variables; for PUF-clone prediction, substitute imputed PUF incomes as +predictors so CPS-only variables come out consistent with imputed +income. + +**Fix**: add the analogous second-stage QRF in +`policyengine_uk_data/datasets/imputations/`, training on FRS with +predictors = demographics + 6 income vars, outputs = a wide set of +"FRS-only" consequential variables including rent, +`mortgage_interest_repayment`, `gift_aid`, `covenanted_payments`, +`charitable_investment_gifts`, `other_deductions`, +`employee_pension_contributions*`, `employer_pension_contributions`, +total wealth components, `capital_allowances`, `deficiency_relief`, +and the full list of `*_reported` benefits. + +## Specific aggregate residuals + +After the formula-side fixes already merged (BASIC/NEW classification +in [PR #1618](https://github.com/PolicyEngine/policyengine-uk/pull/1618), +new State Pension pro-rating + Protected Payment in +[PR #1634](https://github.com/PolicyEngine/policyengine-uk/pull/1634)), +the residual benefit-aggregate gaps against OBR on the Enhanced FRS +(measured May 2026, except the State Pension row, which is taken from +the State Pension page; none re-measured on Microcosm UK) were: + +| Variable | Model | Target | Gap | Likely cause | +|----------|------:|------:|----:|--------------| +| `income_support` | ~£1.07 bn | ~£0.2 bn | **+£0.9 bn** | Legacy benefit near-fully migrated; same `reported > 0` retention issue as the (pre-fix) tax credits. | +| `esa_contrib` | ~£5.56 bn | ~£2.5 bn | **+£3.1 bn** | Large. Likely reported-based take-up plus no phase-out for migrated cases. | +| `attendance_allowance` | ~£8.76 bn | ~£7.0 bn | +£1.8 bn | Cap-rate or take-up issue. | +| `tax_free_childcare` | ~£0.41 bn | ~£0.9 bn | **-£0.5 bn** | Understated by about half: under-imputed eligible families. | +| `state_pension` | £119.0 bn | £131.3 bn | -£12.2 bn | 2024-25, against DWP's Spring Forecast 2026 less payments abroad, on policyengine-uk-data-private 1.57.4, as documented in [state-pension.md](../programs/gov/dwp/state-pension.md). The shortfall arises in building the Enhanced FRS ([policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493)). | + +Each warrants a scoped PR pair (model + data) once the architectural +fixes above land. + +## Items already fixed since #1621 was filed + +The issue body flagged three small bugs; all are now resolved: + +- `tax_credits/min_benefit.yaml` unit was `currency-USD`; it is now `currency-GBP`. +- `benunit_weekly_hours` label said "Average" but the formula sums; label now reads "Total weekly hours worked by adults in the benefit unit". +- `state_pension_type` BASIC/NEW classification (the `is_SP_age` heuristic in the issue) was rewritten in PR #1618. + +This page reflects the *current* gap rather than the issue body, so the +next reviewer working through the queue doesn't waste time on +already-fixed items. + +## Candidate PR sequence + +1. **Convert remaining `would_claim_*` formulas to input-only** (model + side) + reported-anchored assignment in Microcosm UK's `frs_take_up` + stage. Still open: the four formulas above read `_reported` at + runtime on `main`. +2. ~~Port `assign_takeup_with_reported_anchors`~~: done in Microcosm UK + (gap 2). +3. ~~Add second-stage QRF for FRS-only variables on SPI donors~~: done + in Microcosm UK (gap 3). +4. ~~Add `gift_aid` to `IMPUTATIONS`~~: done in Microcosm UK, where + `gift_aid` is a stage-2 output. +5. **Residual benefit-aggregate follow-ups**: re-measure on the + Microcosm release first, then separate small PRs for + IS phase-out (analogous to the WTC/CTC reactive scheme close-out), + ESA contrib investigation, AA calibration, TFC under-imputation, + the State Pension data-side fix (#1632, policyengine-uk-data#493). + +## References + +- Tracking issue: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +- US pattern reference: [`policyengine-us-data#589`](https://github.com/PolicyEngine/policyengine-us-data/pull/589). +- Related state pension docs: [state-pension.md](../programs/gov/dwp/state-pension.md), [#1632](https://github.com/PolicyEngine/policyengine-uk/issues/1632). +- Related calibration scope: [non-uk-benefit-receipt-plan.md](./non-uk-benefit-receipt-plan.md) (#842), separate from this pipeline alignment but interacting with it on the calibration target side. diff --git a/docs/book/policy/future-date-announcements.md b/docs/book/policy/future-date-announcements.md new file mode 100644 index 0000000000..b0649e838b --- /dev/null +++ b/docs/book/policy/future-date-announcements.md @@ -0,0 +1,141 @@ +# Future-dating policy announcements + +PolicyEngine UK supports modelling policy changes that have been +**announced but not yet implemented**, or that take effect at a known +future date. The mechanism is the same one that handles legislated rate +schedules: parameters in `policyengine_uk/parameters/` carry a list of +values keyed by effective date. + +This was originally requested in [#636](https://github.com/PolicyEngine/policyengine-uk/issues/636) +in the context of the Truss "growth plan" announcements in autumn 2022. +The feature has been live and well-exercised since the model moved to +date-keyed parameter values; this page documents the current usage +pattern so that future analyses don't need to reinvent it. + +## How it works + +Every parameter is a `values:` map from effective date to value. When +the tax-benefit system is built, `convert_to_fiscal_year_parameters` +(`policyengine_uk/utils/parameters.py`) turns each parameter into one +value per model year, where model year 2026 is the fiscal year 6 April +2026 to 5 April 2027: + +- by default, the value in force on 30 April of that year; +- for parameters with `fiscal_year_blend: true` in their metadata (fuel + duty, for example), the day-weighted average over the fiscal year, so a + change part-way through the year counts for the share of the year it + applies; +- parameters with `preserve_calendar_dates: true` keep their statutory + dates, and their formulas annualise explicitly. + +Example from +[`gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml): + +```yaml +values: + ... + 2021-04-06: + value: 12_570 + ... + 2027-04-06: + value: 12_570 + ... + 2030-04-06: + value: 12_570 + metadata: + reference: + - title: Finance Act 2021 s. 5 (2), as amended by Finance Act 2026 s. 10 (3) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/10 +``` + +The 2030-04-06 row records a future-dated decision: the freeze to +2030-31, announced at the 2025 Budget and since enacted by Finance Act +2026 s. 10. The file has no uprating. From the tax year after its last +row, `policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py` +applies the Income Tax Act 2007 s. 57 CPI indexation year by year. So +model years up to 2030 return £12,570, and indexation starts in 2031. + +## Patterns for common cases + +### Announced but reversed before taking effect + +Some announcements are walked back before the effective date. The +Truss "growth plan" basic-rate cut to 19% (announced September 2022, +reversed October 2022) is the canonical example. To represent this +honestly: + +1. Add the announced value at the announced effective date with a + reference to the announcement. +2. Add a *second* row at the reversal date with the original value (or + the actual landing value) and a reference to the reversal + announcement. + +The baseline then follows what was finally enacted. PolicyEngine UK has +no "policy as announced on date X" switch, so to score an announcement as +it stood before a reversal, apply it as a reform (see +the [excise duties page](../../engineering/excise-duties.md) for how +dated reforms are annualised). + +### Phased-in or staggered changes + +Phased rate changes are the most common future-dating pattern. Each +phase is a row at its effective date with a separate `reference`. The +`national_insurance/class_1/rates/employee/main.yaml` trajectory is a +good worked example: 13.25% from April 2022 under the Health and Social +Care Levy, back to 12% from November 2022 when the levy was repealed, +then the cuts to 10% in January 2024 and 8% in April 2024. + +### Announced abolitions + +For an abolition announced ahead of its effective date (for example the +2025 Budget removal of the two-child limit, in +[`gov/dwp/universal_credit/elements/child/limit/child_count.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/child_count.yaml)): + +- The relevant parameter (`gov.dwp.universal_credit.elements.child.limit.child_count`) + has its limiting value (2) up to the announced effective date + (2026-04-06) and `.inf` from that date onwards. +- Model year 2025 (fiscal year 2025-26) returns 2, so the limit applies. +- From model year 2026 the value is `.inf`, so the limit no longer + restricts the UC child element. + +The same pattern applies in reverse to one-off or time-limited +payments. The means-tested Cost-of-Living Payment in +[`gov/treasury/cost_of_living_support/means_tested_households/amount.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/treasury/cost_of_living_support/means_tested_households/amount.yaml) +is £650 from 2022, £900 from 2023 and goes back to zero from +2024-01-01. + +## Best practices + +1. **Cite the announcement in `metadata.reference`** at every + future-dated row. Where possible, cite both the announcement and + the legislation that enacts it once it is published. The 2022-04-06 + row of `national_insurance/class_1/rates/employee/main.yaml`, for + example, cites both the Health and Social Care Levy Act 2021 and the + Budget document. +2. **Don't extrapolate**. Each future-dated row should reflect a + specific announcement. If a parameter would otherwise move with + uprating, let the uprating handle it. Adding a "frozen forever" row is + a policy assertion that needs its own reference. +3. **Check how the parameter is annualised.** A change dated part-way + through a fiscal year only counts for part of the year if the + parameter has `fiscal_year_blend: true`; otherwise the model year takes + the value in force on 30 April. + +## What this isn't + +This is not the same as **scenario analysis** or **policy reform** +parameters under `gov.contrib.*`. Those are reform-side toggles for +hypothetical changes the user wants to evaluate; future-dated parameter +rows under `gov.hmrc.*`, `gov.dwp.*` etc. are **baseline** policy +representing what is actually announced. + +If a future change is uncertain or contested, model it under +`gov.contrib.*` until it's announced; once announced, move it into the +baseline tree with an effective-date row. + +## References + +- Issue [#636](https://github.com/PolicyEngine/policyengine-uk/issues/636): the original request, from the Truss growth plan period. +- PolicyEngine research: [Tax cuts in Prime Minister Truss's growth plan](https://www.policyengine.org/uk/research/tax-cuts-in-prime-minister-trusss-growth-plan), a worked example of modelling an announcement before its reversal. +- [PolicyEngine Core](https://github.com/PolicyEngine/policyengine-core): the date-keyed parameter mechanism underneath. +- `convert_to_fiscal_year_parameters` in `policyengine_uk/utils/parameters.py`: how dated values become model-year values. diff --git a/docs/book/policy/indirect-tax-effects-plan.md b/docs/book/policy/indirect-tax-effects-plan.md new file mode 100644 index 0000000000..7c1fb0b296 --- /dev/null +++ b/docs/book/policy/indirect-tax-effects-plan.md @@ -0,0 +1,136 @@ +# Indirect tax effects of reforms (planned) + +```{note} +**Planning page.** PolicyEngine UK already routes ad-hoc indirect-tax +revenue changes through `consumer_incident_tax_revenue_change` at the +household level, but this is a **manual** lever the user has to set via +the `gov.contrib.policyengine.budget.consumer_incident_tax_change` +parameter. There is no automatic pass-through from changes in direct +taxes (e.g. income tax, NI) to consumption-mediated indirect taxes. +This page sets out a proposed automatic route, tracked in +[#1114](https://github.com/PolicyEngine/policyengine-uk/issues/1114) (still +open, October 2026). +``` + +## What this is about + +When a direct tax reform changes a household's disposable income, real +households respond by adjusting consumption. That changes the indirect +tax revenue (VAT, excise duties, fuel duty) the household generates. +Right now PolicyEngine UK computes: + +1. **Direct effect**: the full structural change to income tax, NI, UC + and so on under the reform. +2. **Behavioural response on labour supply**: modelled with the + substitution and income elasticities under + `gov.simulation.labour_supply_responses` (`policyengine_uk/dynamics/labour_supply.py`, + `employment_income_behavioral_response`). +3. **Indirect-tax response**: *not modelled*. Households' consumption + bundles are read from the dataset and don't shift in response to + income changes. + +#1114 reports that UKMOD handles this in its indirect tax (TCO) module with +an aggregate **consumption elasticity of 0.8**: a 1% rise in net income gives a 0.8% rise in +consumption, which then flows through VAT and excise revenue. That figure +has not yet been checked against UKMOD's own documentation, so it needs a +primary source before it is used as a default. + +## Scope + +### Phase 1: household-level consumption response + +- New parameter `gov/simulation/indirect_tax_response/consumption_elasticity.yaml`, + defaulting to 0 (no response) until a sourced value such as UKMOD's is + confirmed and cited in its metadata. +- New variable `consumption_response_factor` (Household, YEAR) = + `1 + elasticity * (net_income_change / baseline_net_income)`. +- Apply the factor to the twelve COICOP categories that `consumption` + adds up (`food_and_non_alcoholic_beverages_consumption`, + `alcohol_and_tobacco_consumption`, `transport_consumption` and so on). + VAT reads `consumption` (through `full_rate_vat_consumption` and + `reduced_rate_vat_consumption`), so it picks up the change. +- The duties do not read those categories. Fuel duty reads + `petrol_litres` and `diesel_litres`, which divide the `petrol_spending` + and `diesel_spending` inputs by pump prices. Alcohol duty reads the + `*_litres` inputs under `variables/input/consumption/alcohol/`, and + tobacco duty reads `cigarettes_per_week` and the `*_grams_per_week` + inputs under `variables/input/consumption/tobacco/`. The same factor + needs to be applied to those spending and quantity inputs. + +### Phase 2: heterogeneity by category + +- A single economy-wide elasticity hides that some categories (food + staples, domestic energy) are nearly income-inelastic while others + (recreation, restaurants) are not. Replace the single + `consumption_elasticity.yaml` with a per-category set, calibrated to + ONS *Living Costs and Food Survey* income-elasticity estimates. + +### Phase 3: incidence link + +- Today's `consumer_incident_tax_revenue_change` distributes a + user-specified aggregate across households using consumption shares + (see + [`consumer_incident_tax_revenue_change.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/policyengine/consumer_incident_tax_revenue_change.py)). + In Phase 3 the variable derives its aggregate **from** the Phase-2 + variables so the user doesn't need to set a separate budget lever. + +## Implementation outline + +### Variables + +- `consumption_response_factor` (Household, YEAR), Phase 1. +- `disposable_income_change` (Household, YEAR): the difference between + reform and baseline `household_net_income`. The labour-supply module + already computes a clipped relative version of this per person + (`relative_income_change`, under + `variables/gov/simulation/labour_supply_response/`); Phase 1 needs a + household-level version in pounds. +- Optional `consumption_response_factor_by_category` (Household, + per category), Phase 2. + +### Hook points + +Each `*_consumption` input variable currently reads straight from the +dataset. Two options for the hook: + +1. **Decorate at the input layer**: add `defined_for` / + `default_formula` that multiplies by `consumption_response_factor`. + This is clean but ties the consumption inputs to indirect-tax + modelling. +2. **Insert a derived layer**: keep `*_consumption_baseline` (reads from + data) and introduce `*_consumption` formulas that apply the response + factor. This is the safer route: users who do not need the indirect + tax response keep the pre-reform consumption as + `*_consumption_baseline`. + +Recommendation: route (2). The dataset-side variable becomes +`*_consumption_baseline`; the response factor is applied at +`*_consumption`. + +### Tests + +- A reform that drops income tax for low-income households should + produce a *positive* `consumer_incident_tax_revenue_change` aggregate + (more take-home pay -> more consumption -> more VAT). +- A reform that raises NI should produce a *negative* aggregate. +- The aggregate should track elasticity × (sum of net income change × VAT-equivalent rate) + to within a small tolerance. + +## Open questions + +- Should the elasticity vary by household income decile? Empirical + evidence suggests low-income households have higher marginal + consumption propensity. +- Should saving be modelled explicitly (the residual after consumption)? + Phase 1 implicitly assumes any income change above the elasticity + applies to saving. +- How does this interact with the existing labour-supply response so + we don't double-count? + +## References + +- UKMOD documentation, to confirm the 0.8 consumption elasticity reported in #1114 (not yet checked). +- ONS [Living Costs and Food Survey](https://www.ons.gov.uk/peoplepopulationandcommunity/personalandhouseholdfinances/expenditure/bulletins/familyspendingintheuk/latest): income-elasticity calibration source for Phase 2. +- HMRC, [VAT annual statistics](https://www.gov.uk/government/statistics/value-added-tax-vat-annual-statistics): calibration target for the aggregate. +- Existing infrastructure: [`consumer_incident_tax_revenue_change`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/policyengine/consumer_incident_tax_revenue_change.py) and the `gov/simulation/labour_supply_responses/` parameter tree for the parallel labour-supply pattern. +- Related issue: [#1114](https://github.com/PolicyEngine/policyengine-uk/issues/1114). diff --git a/docs/book/programs/gov/cms/child-maintenance.md b/docs/book/programs/gov/cms/child-maintenance.md new file mode 100644 index 0000000000..7dc437cd20 --- /dev/null +++ b/docs/book/programs/gov/cms/child-maintenance.md @@ -0,0 +1,135 @@ +# Child maintenance (planned) + +```{warning} +**Not yet modelled in detail.** PolicyEngine UK has two generic person-level +inputs, `maintenance_income` and `maintenance_expenses`. They do not tell +child maintenance apart from maintenance between former partners, and the +model does not calculate a Child Maintenance Service (CMS) award. This page +sets out the scope for doing that, tracked in +[#669](https://github.com/PolicyEngine/policyengine-uk/issues/669) (still +open, October 2026). +``` + +## What the model does today + +- `maintenance_income` is added to `market_income`, `household_market_income` + and `hbai_household_net_income`. +- `maintenance_expenses` is subtracted in `market_income` and + `hbai_household_net_income`. +- Neither variable feeds any means test. Universal Credit unearned income is + the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`, + which contains no maintenance, and UC earned income does not net off + maintenance paid. +- `uc_deductions` does cover child maintenance collected from a paying + parent's UC award, but only in aggregate: it sits in the above-cap excess + of `uc_deduction_rate` with last resort deductions, estimated from DWP + deduction data rather than from `maintenance_expenses`. + +For **child** maintenance that is the right answer for UC: + +- **Receiving parent:** child maintenance is not unearned income under + [UC Regulations 2013 reg. 66(1)](https://www.legislation.gov.uk/uksi/2013/376/regulation/66), + so it is fully disregarded. +- **Paying parent:** maintenance paid is not deducted, so the paying parent is + means-tested on their full earnings. + +This asymmetry is the work-incentive problem described by the Centre for Social +Justice in [*The Hidden Parent Poverty Trap: Child Maintenance and Universal +Credit*][csj]. The model already reproduces it, because neither input reaches +UC. + +The gap is **maintenance between former spouses or civil partners**. +Reg. 66(1)(d) counts it as unearned income in UC, but the model cannot see it, +because it shares `maintenance_income` with child maintenance. Those +recipients' UC is overstated. + +## Scope + +### Phase 1: separate the inputs + +- New input `child_maintenance_received` (Person, GBP, annual). +- New input `child_maintenance_paid` (Person, GBP, annual). +- Keep `maintenance_income` and `maintenance_expenses` for maintenance + between former partners, and say so in their documentation. +- In the data pipeline + ([Microcosm UK](https://github.com/PolicyEngine/microcosm)), route the FRS + child-maintenance items to the new inputs and the spousal items to the + existing ones. +- HBAI income keeps both kinds, received and paid. + +### Phase 2: count spousal maintenance in UC + +- Add `maintenance_income` (now spousal only) to the UC unearned income list + from 2013-04-29, citing reg. 66(1)(d). +- Leave `child_maintenance_received` and `child_maintenance_paid` out of UC. +- Check the legacy benefits and Pension Credit the same way before adding + either input to their income definitions. + +### Phase 3: model the CMS calculation + +Derive the CMS weekly amount when the inputs are available, from the paying +parent's gross weekly income +([GOV.UK](https://www.gov.uk/how-child-maintenance-is-worked-out); +[Child Support Act 1991 Sch. 1](https://www.legislation.gov.uk/ukpga/1991/48/schedule/1)): + +| Paying parent's gross weekly income | Rate | Weekly amount | +|---|---|---| +| Below £7 | Nil | £0 | +| £7 to £100, or on benefits | Flat | £7 | +| £100.01 to £199.99 | Reduced | Formula | +| £200 to £3,000 | Basic | 12% / 16% / 19% of income up to £800 for 1 / 2 / 3+ children, then 9% / 12% / 15% of income from £800 to £3,000 | +| Above £3,000 | Basic, capped | Income above £3,000 is ignored; the receiving parent can apply to the courts | +| Not enough information | Default | £38 / £51 / £64 | + +Then: + +- **Shared care:** the amount falls by 1/7 for 52 to 103 nights a year, 2/7 + for 104 to 155, 3/7 for 156 to 174, and by half plus £7 a week for 175 or + more. +- **Other children:** an adjustment applies for other children living with + the paying parent. + +The income bands are fixed in legislation, not uprated each year. + +Phase 3 is optional for UC analysis, which needs only Phases 1 and 2. It +allows reforms to the CMS rates or bands to be scored. + +## Implementation outline + +### Parameters under `gov/cms/` + +- `income_bands/`: the £7, £100, £200, £800 and £3,000 limits. +- `rate/flat.yaml`, `rate/default/` and `rate/basic/` (12/16/19% and + 9/12/15%). +- `rate/reduced/`: the reduced-rate formula. +- `shared_care/`: the night bands and fractions. + +### Variables under `variables/gov/cms/` + +- `child_maintenance_gross_weekly_income` (Person) +- `child_maintenance_obligation_pre_shared_care` (Person) +- `child_maintenance_shared_care_reduction` (Person) +- `child_maintenance_paid_cms_basis` (Person, derived award) + +## Data needs + +- **FRS** records maintenance received and paid at person level. Phase 1 + needs a check of how far its source items separate child maintenance + from spousal maintenance. +- **DWP [Child Maintenance Service statistics][cms-stats]** (quarterly, latest + data to June 2026) give the CMS caseload and amounts. They support checking + the new inputs, bearing in mind that many families use private + ("family-based") arrangements outside the CMS. + +## References + +- Centre for Social Justice, [The Hidden Parent Poverty Trap: Child + Maintenance and Universal Credit][csj]. +- GOV.UK, [How child maintenance is worked out](https://www.gov.uk/how-child-maintenance-is-worked-out). +- [Child Support Act 1991](https://www.legislation.gov.uk/ukpga/1991/48/contents) and [Schedule 1](https://www.legislation.gov.uk/ukpga/1991/48/schedule/1). +- [The Child Support Maintenance Calculation Regulations 2012 (SI 2012/2677)](https://www.legislation.gov.uk/uksi/2012/2677/contents). +- [The Universal Credit Regulations 2013, reg. 66](https://www.legislation.gov.uk/uksi/2013/376/regulation/66). +- DWP, [Child Maintenance Service statistics][cms-stats]. + +[csj]: https://www.centreforsocialjustice.org.uk/library/the-hidden-parent-poverty-trap-child-maintenance-and-universal-credit +[cms-stats]: https://www.gov.uk/government/collections/statistics-on-the-2012-statutory-child-maintenance-scheme diff --git a/docs/book/programs/gov/dfe/regional-childcare-plan.md b/docs/book/programs/gov/dfe/regional-childcare-plan.md new file mode 100644 index 0000000000..225a5291e6 --- /dev/null +++ b/docs/book/programs/gov/dfe/regional-childcare-plan.md @@ -0,0 +1,170 @@ +# Regional childcare programmes (planned) + +```{warning} +**Partially modelled.** PolicyEngine UK models the **England** funded +childcare programmes (Universal, Extended and Targeted Childcare +Entitlements; Care to Learn; Student Finance England Childcare Grant) +and the **UK-wide** Tax-Free Childcare scheme. The devolved +administrations operate parallel schemes that are not yet modelled +beyond the Scottish Universal Funded ELC for 3- and 4-year-olds added +in [#1643][pr-1643]. This page captures the proposed scope, tracked +under [#1008](https://github.com/PolicyEngine/policyengine-uk/issues/1008) +and the broader [#1644 audit][issue-1644]. +``` + +## What is modelled today + +The current childcare coverage is captured in +[`policyengine_uk/programs.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/programs.yaml) (see the +[#1644 split into per-programme rows][issue-1644]) and works as follows: + +- **UK-wide**: Tax-Free Childcare (HMRC); Universal Credit childcare + element (DWP); Working Tax Credit childcare element (legacy, HMRC + administered, modelled under `gov/dwp/`; see + [tax-credits.md](./../dwp/tax-credits.md)). +- **England-only** (gate on `country == ENGLAND`): + Universal Childcare Entitlement (15 hours for 3- and 4-year-olds), + Extended Childcare Entitlement (working parents, now reaching down to + children aged nine months), Targeted Childcare Entitlement (2-year-olds + in families on qualifying benefits or low incomes), Care to Learn, + Student Finance England Childcare Grant. +- **Scotland**: eligibility for the universal funded ELC for 3- and + 4-year-olds (`scottish_funded_early_learning_childcare_eligible`, + including the statutory start-date rule for 3-year-olds) and its 1,140 + hours a year, added in [#1643][pr-1643]. No funding value is attached + yet, so it does not reach household income. +- **Wales, Northern Ireland**: no programmes modelled. + +## Proposed scope by jurisdiction + +### Scotland: three remaining strands + +The [#1644 audit][issue-1644] flagged three Scottish gaps: + +1. **Eligible-two-year-old strand** of the funded ELC programme + (vulnerable or low-income 2-year-olds under the + [Provision of Early Learning and Childcare (Specified Children) + (Scotland) Order 2014][sssi-196-2014]). Modelled the same way as the + 3- and 4-year-old strand but gated on UC, tax credits or other + qualifying benefits. +2. **Student Awards Agency Scotland (SAAS) Lone Parents' Grant, + Dependants' Grant, and Childcare Fund** for full-time HE students: + the Scottish equivalent of Student Finance England's Childcare + Grant. Income-tested. +3. **Support for young parents in further education**, the role Care to + Learn plays in England. The [Education Maintenance Allowance][scot-ema] + still runs in Scotland for 16- to 19-year-olds, applied for through + local councils and colleges, but it is a maintenance payment rather + than childcare support, so the childcare side needs its own scoping. + +### Wales + +The audit identified four Welsh programmes: + +1. **Childcare Offer for Wales**: 30 hours a week for working parents + of 3- and 4-year-olds (term time plus some holiday weeks). + Modelled the same as the English Extended Childcare Entitlement + with Welsh-specific income tests and hour caps. Statutory basis: + [Childcare Funding (Wales) Act 2019][wales-act]. +2. **Flying Start**: a universal offer in disadvantaged areas for + 2- to 3-year-olds (12.5 hours a week). Eligibility is geographic, + based on lower layer super output areas. +3. **Student Finance Wales Childcare Grant**, parallel to the English + Childcare Grant. +4. **Welsh EMA**, parallel to the Scottish EMA. + +### Northern Ireland + +The audit identified four NI programmes: + +1. **Pre-school Education Programme (PSEP)**: 12.5 hours a week in the + year before school. Universal. +2. **Sure Start**: younger children in disadvantaged areas, including a + programme for 2-year-olds. +3. **Student Finance NI Childcare Grant**. +4. **NI EMA**. + +## Implementation outline + +For each new programme: + +- New parameter tree under `parameters/gov///` + capturing the income tests, hours offered, and weekly funding rates. +- New variable(s) under `variables/gov///` gated on + the appropriate `country == ...` check. +- A `_eligible` companion variable that the new programme + variable composes with the rate parameter, the same pattern as the + English entitlement trio. +- A `programs.yaml` row per programme, bound to the new variable and + parameter prefix. + +### Structural items deferred to this work + +The [#1644 audit][issue-1644] also flagged two structural items that +become more pressing once the regional programmes land: + +- `weeks_per_year` and `childcare_funding_rate` currently sit at + `gov.dfe.*` root but encode English term-week and funding-rate + assumptions. They should be regionalised (for example + `gov.scottish_government.funded_early_learning_childcare.weeks_per_year`) + once the Scotland, Wales and NI programmes are modelled. +- No aggregating variable across programmes exists (for example "total + subsidised childcare hours per child per week"). The API surfaces + the per-programme variables but can't answer "what's this family's + combined childcare support" without the caller summing. + +Both belong in a separate cleanup PR rather than mixed into the +per-programme work. + +## Data needs + +Most regional programmes are point-in-time per-hour funding rather +than per-household payments, so the data side is comparatively easy: + +- **FRS** provides `region` and `country` at the household level, + enough to gate eligibility. +- **Welsh Government Statistical Bulletins** publish Flying Start + and Childcare Offer caseloads by local authority. +- **Scottish Government Childcare and Early Years statistics** provide + Scottish funded ELC caseloads. +- **Department of Education NI** publishes PSEP caseloads. +- The SAAS, Student Finance Wales and NI childcare grants need the same + treatment as the English `childcare_grant`: calibration against + published award totals. + +The aggregating challenge is choosing whether to expose **subsidised +hours** or **subsidised cost** as the primary household-level +variable. Recommendation: subsidised cost, since that is how the English +entitlements already enter `household_benefits` and `gov_spending`. + +## Open questions + +- Should the Scottish ELC universal strand and the eligible-two-year-old + strand be modelled as two separate variables (matching how England + splits Universal / Extended / Targeted), or as one variable with the + age-and-eligibility logic inside? Recommendation: separate, mirroring + the English pattern for queryability. +- Welsh Flying Start is geographic. Microcosm UK, the dataset being + built to replace the Enhanced FRS, assigns each household in England + and Wales to a 2021 Output Area, which nests in LSOAs, so eligibility + by area becomes possible on that dataset once the Flying Start area + list is sourced. +- EMA ended in England but still runs in Scotland, Wales and Northern + Ireland. Today `child_ema` and `adult_ema` are reported amounts with + no formula. Should the devolved schemes be modelled under the existing + `gov.dfe.care_to_learn` umbrella or as separate regional schemes? + +## References + +- Audit: [#1644](https://github.com/PolicyEngine/policyengine-uk/issues/1644), the full childcare coverage audit. +- Tracking: [#1008](https://github.com/PolicyEngine/policyengine-uk/issues/1008), the original Scotland, Wales and NI gap report. +- Recent landed work: [#1643][pr-1643], Scottish universal funded ELC. +- Existing England docs (for the pattern to mirror): the entitlement variables under [`policyengine_uk/variables/gov/dfe/`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/dfe). +- Welsh childcare statute: [Childcare Funding (Wales) Act 2019][wales-act]. +- Scottish eligible-2-year-old statute: [SSSI 2014/196][sssi-196-2014]. + +[issue-1644]: https://github.com/PolicyEngine/policyengine-uk/issues/1644 +[pr-1643]: https://github.com/PolicyEngine/policyengine-uk/pull/1643 +[wales-act]: https://www.legislation.gov.uk/anaw/2019/1/contents +[sssi-196-2014]: https://www.legislation.gov.uk/ssi/2014/196/contents/made +[scot-ema]: https://www.mygov.scot/ema diff --git a/docs/book/programs/gov/dwp/bereavement-support-plan.md b/docs/book/programs/gov/dwp/bereavement-support-plan.md new file mode 100644 index 0000000000..86da510bd1 --- /dev/null +++ b/docs/book/programs/gov/dwp/bereavement-support-plan.md @@ -0,0 +1,162 @@ +# Bereavement support: BSP formula and historical WPA (planned) + +```{warning} +**Currently reported-only.** PolicyEngine UK models the Bereavement +Support Payment (BSP) as `adds = ["bsp_reported"]`, so the value +flows through from the dataset, with no derived eligibility or +rule-based amount. This page captures the proposed formula-based +extension covering both BSP and the legacy Widowed Parent's Allowance +(WPA) for historical analysis. Tracks +[#466](https://github.com/PolicyEngine/policyengine-uk/issues/466) and +the related historical-WPA ask in +[#465](https://github.com/PolicyEngine/policyengine-uk/issues/465). +Updated October 2026: still reported-only; the facts below were checked +against GOV.UK and legislation.gov.uk. The data notes cover both the +certified Enhanced FRS (policyengine-uk-data) and Microcosm UK, the +dataset being built to replace it. +``` + +## What's currently modelled + +Just the FRS-reported amount: + +```python +class bsp(Variable): + value_type = float + entity = Person + label = "Bereavement Support Payment" + definition_period = YEAR + unit = GBP + + adds = ["bsp_reported"] +``` + +That means: + +- Reform analyses changing BSP rates have no household-level surface. +- Anyone bereaved after the FRS reference week never appears as a BSP + recipient even if eligible. +- The legacy WPA has no variable of its own. Both the Enhanced FRS + ([`frs.py`](https://github.com/PolicyEngine/policyengine-uk-data/blob/main/policyengine_uk_data/datasets/frs.py)) + and Microcosm UK fill `bsp_reported` from FRS benefit codes 6 and 9 + ([`frs_spine.py`](https://github.com/PolicyEngine/microcosm/blob/75167a68/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py#L825)), + and Microcosm's own + [source notes](https://github.com/PolicyEngine/microcosm/blob/d1fda720/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml#L1870) + say code 6 mixes benefits and code 9 is tax-free War Widow's Pension. So the reported "BSP" also carries legacy + widow's benefits and War Widow's Pension. + +## Proposed scope + +### Phase 1: rule-based BSP formula + +The BSP under the [Pensions Act 2014, Part 5][pa-2014-5] and the +[Bereavement Support Payment Regulations 2017][bsp-regs] (SI 2017/410) +pays: + +- A **lump sum** of either £2,500 (standard rate) or £3,500 (higher + rate, with a dependent child). +- **18 monthly payments** of £100 (standard) or £350 (higher), for a + death on or after 6 April 2017 + ([GOV.UK](https://www.gov.uk/bereavement-support-payment/what-youll-get)). + +The higher rate applies if, when the partner died, the claimant was +getting (or entitled to) Child Benefit for a child living with them, or +was pregnant. The +[Bereavement Benefits (Remedial) Order 2023](https://www.legislation.gov.uk/uksi/2023/134/contents) +(SI 2023/134) extended BSP and WPA to cohabiting partners with +children, with effect back to 30 August 2018, after the Supreme Court's +2018 McLaughlin judgment. It didn't change the number of payments. + +To model this we need: + +- `is_bereaved_recently` (Person, YEAR): a new input flagging + whether the person has been bereaved in the last 18 months. +- `bsp_higher_rate_eligible` (Person, YEAR): whether they have a + dependent child. +- `bsp_months_remaining` (Person, YEAR): how many of the 18 monthly + instalments fall within the simulation year. + +The BSP formula then becomes: + +``` +bsp = ( + lump_sum_amount * is_bereaved_recently + + monthly_amount * bsp_months_remaining +) +``` + +Both `lump_sum_amount` and `monthly_amount` parameter trees split on +`bsp_higher_rate_eligible` (£3,500 / £350 vs £2,500 / £100). + +### Phase 2: historical WPA under the same formula + +Widowed Parent's Allowance (under [Social Security Contributions and +Benefits Act 1992 s. 39A][sscba-39a]) was paid for deaths from April +2001 up to 5 April 2017. The benefit: + +- Continued for as long as the surviving spouse had a dependent child. +- Was uprated annually. +- Was replaced by BSP for deaths from 6 April 2017. + +For historical analyses the model should: + +- Add `wpa_eligible` (Person, YEAR): partner died between April 2001 + and 5 April 2017, the survivor is under State Pension age and is + entitled to Child Benefit. +- Add `widowed_parents_allowance` (Person, YEAR) as a derived + formula reading the WPA rate parameter (which would need + backfilling under `gov/dwp/wpa/rate.yaml`). + +WPA has no new awards, but existing awards continue while the +surviving parent has a dependent child, so some recipients remain. In +the current data they sit inside `bsp_reported` (see above), so they +are already counted in income; the gap is that they can't be told +apart from BSP or reformed separately. + +### Phase 3: data-side bereavement flag + +`is_bereaved_recently` doesn't exist in the FRS. The FRS is a +cross-section, not a panel, and doesn't record when a partner died, so +the flag would need imputing in the data (Microcosm UK, as the +replacement dataset): + +- start from FRS respondents who report BSP (once it is separated from + the other codes, as above) and from widowed respondents; +- calibrate to DWP's BSP and WPA caseloads ([Stat-Xplore](https://stat-xplore.dwp.gov.uk/)) by age, sex + and region. + +This is the heaviest of the three phases; without it the new +variables work for the household calculator (which can input the flag +directly) but not for microsim aggregates. + +## Open questions + +- BSP isn't uprated each year. The rates have been £2,500/£3,500 and + £100/£350 since 2017. The model's `bsp_reported` is nonetheless + uprated with CPI, which suits the War Widow's Pension and legacy + widow's benefits it also contains but not BSP itself. Should the new + parameter tree carry an explicit + `uprating: gov.economic_assumptions.indices.obr.consumer_price_index` + to mirror what other DWP rates do, or hold flat to match the + historical reality? +- WPA recipients who would have been on BSP under the new rules: do we + want a reform-side parameter "treat all WPA receipts as if they were + BSP" for counterfactual analysis? +- The 2023 Remedial Order extended BSP to cohabiting partners with + children, with effect from 30 August 2018. Eligibility should not + require marriage or civil partnership for deaths from that date where + there are children. + +## References + +- [Pensions Act 2014, Part 5](https://www.legislation.gov.uk/ukpga/2014/19/part/5): primary statute for BSP. +- [Bereavement Support Payment Regulations 2017 (SI 2017/410)][bsp-regs]. +- [Bereavement Benefits (Remedial) Order 2023 (SI 2023/134)](https://www.legislation.gov.uk/uksi/2023/134/contents): cohabiting-parent extension, effective from 30 August 2018. +- [SSCBA 1992 s. 39A][sscba-39a]: legacy WPA basis. +- gov.uk, [Bereavement Support Payment](https://www.gov.uk/bereavement-support-payment) and [Widowed Parent's Allowance](https://www.gov.uk/widowed-parents-allowance). +- DWP, [Stat-Xplore](https://stat-xplore.dwp.gov.uk/): Bereavement Support Payment and Widowed Parent's Allowance caseloads. +- Issues: [#466](https://github.com/PolicyEngine/policyengine-uk/issues/466) (BSP formula), [#465](https://github.com/PolicyEngine/policyengine-uk/issues/465) (historical WPA). + +[pa-2014-5]: https://www.legislation.gov.uk/ukpga/2014/19/part/5 +[bsp-regs]: https://www.legislation.gov.uk/uksi/2017/410/contents +[sscba-39a]: https://www.legislation.gov.uk/ukpga/1992/4/section/39A diff --git a/docs/book/programs/gov/dwp/cold-weather-payment-plan.md b/docs/book/programs/gov/dwp/cold-weather-payment-plan.md new file mode 100644 index 0000000000..b24fd6315b --- /dev/null +++ b/docs/book/programs/gov/dwp/cold-weather-payment-plan.md @@ -0,0 +1,152 @@ +# Cold Weather Payment (planned) + +```{warning} +**Not yet modelled.** The Cold Weather Payment (CWP) is a DWP payment +of **£25 per 7-day period of very cold weather** to qualifying +households in England and Wales. Northern Ireland runs its own scheme +on the same terms (1 November 2026 to 31 March 2027, +[nidirect](https://www.nidirect.gov.uk/articles/cold-weather-payment)). +PolicyEngine UK doesn't model it. This page sets out the proposed +scope, tracked in +[#435](https://github.com/PolicyEngine/policyengine-uk/issues/435). + +In Scotland the equivalent is the **Winter Heating Payment**, a flat +annual payment (£62.00 from April 2026, regulation 9 of +[SSI 2023/16](https://www.legislation.gov.uk/ssi/2023/16/contents) as +amended), which +replaced Cold Weather Payment there in 2023. It isn't modelled either. +The separate Pension Age Winter Heating Payment, which replaced Winter +Fuel Payment in Scotland, is modelled under +`gov.social_security_scotland.pawhp`. Updated October 2026: amounts, +legislation and the Scotland and Northern Ireland position checked. +``` + +## What CWP is + +Defined in [The Social Fund Cold Weather Payments (General) +Regulations 1988][cwp-1988] (SI 1988/1724). A household qualifies if +**all** of the following hold: + +- The address is in a postcode linked to a weather station where the + average temperature is recorded as, or forecast to be, **0°C or below + over 7 consecutive days** between 1 November and 31 March. +- A member of the benefit unit receives a **qualifying benefit**: + Pension Credit; income-related ESA in the support or work-related + activity group, or with a severe or enhanced disability premium, a + pensioner premium, a disabled child or a child under 5 (the + Regulations extend the same premium test to Income Support and + income-based JSA); Universal Credit when neither the claimant nor a + partner is employed or gainfully self-employed and someone has + limited capability for work or there is a child under 5, or, whether + working or not, when the award includes a disabled child amount; or + Support for Mortgage Interest with one of those premiums or children. + The [GOV.UK eligibility page](https://www.gov.uk/cold-weather-payment/eligibility) + has the exact conditions. + +The payment is £25 per 7-day cold period, and each further cold +period triggers another payment. Spending varies a lot with the winter: in +2024-25, for example, about 1.4 million payments worth around £35 +million were made in England and Wales +([DWP, April 2025](https://www.gov.uk/government/news/over-35-million-in-cold-weather-payments-support-paid-this-winter)). +DWP doesn't count actual payments through the winter; it publishes the +estimated number of eligible people at the start of each season and the +weekly triggers, from which payments can be estimated. + +## Why this is harder than other DWP benefits + +The eligibility test combines an administrative benefit check (clean +for PolicyEngine) with a **geographic weather event** (no FRS +analogue). Modelling the weather side has three plausible approaches: + +1. **Expected-value parameterisation**: a single national + `cold_weather_payments_expected_periods` parameter capturing the + long-run-average number of 7-day cold periods triggered per winter. + Multiply by £25 and by the qualifying household flag to produce the expected + annual payment. Easy to implement, accurate in aggregate, wrong in + any specific winter. +2. **Regional weather mixing**: a per-region weather-event + distribution (for example, the North East has more cold periods than London on + average), parameterised from Met Office historic data. Captures the + distributional skew without forecasting any particular winter. +3. **Year-specific lookups**: tabulate the DWP-published *Cold Weather + Payment statistics* by region by year and use those as historical + inputs. Most accurate for back-cast simulations; needs ongoing + updating. + +Recommendation: **option 2 (regional mixing)** for prospective analysis +and option 3 for back-casts where the data is available. + +## Proposed scope + +### Phase 1: qualifying-benefit gate and expected-value payment + +- New parameter `gov.dwp.cold_weather_payment.amount` = £25. The amount + was lower in earlier years, so take any backdated values from the + amendment history of SI 1988/1724. +- New parameter + `gov.dwp.cold_weather_payment.expected_periods_per_year` = + long-run national average from DWP CWP statistics. +- New variable `cold_weather_payment_eligible` (BenUnit, YEAR) that + fires if any benefit unit member receives one of the qualifying benefits + with the relevant addition / age trigger. +- New variable `cold_weather_payment` (BenUnit, YEAR) = `amount` × + `expected_periods_per_year` × `eligible`. + +This delivers a model that gets the policy-relevant slice right +(*who* qualifies for CWP and how it interacts with their other +benefits) at the cost of smoothing year-to-year weather variance. + +### Phase 2: regional weather mixing + +Add a `gov.dwp.cold_weather_payment.expected_periods_by_region` table +parameterised from Met Office observation data, and a household-side +lookup by `region`. + +### Phase 3: Scotland's Winter Heating Payment + +Paid by Social Security Scotland under +[The Winter Heating Assistance (Low Income) (Scotland) Regulations 2023](https://www.legislation.gov.uk/ssi/2023/16/contents) +(SSI 2023/16). The qualifying benefits are essentially the same as for +Cold Weather Payment (Pension Credit with no further condition; the +other benefits with the same disability, pensioner-premium or +child-under-5 conditions), assessed in a qualifying week (2 to 8 +November in 2026), but the payment is a flat annual amount with no +weather trigger. So: + +- `winter_heating_payment_eligible`: the Phase 1 eligibility rule plus + residence in Scotland. Reuse the Phase 1 variable rather than a second + copy of the benefit conditions. +- `winter_heating_payment` = `amount` × eligible, with `amount` + uprated each year (£62.00 from April 2026). + +## Data needs + +- DWP, [Cold Weather Payment statistics](https://www.gov.uk/government/collections/social-fund-cold-weather-payments): estimated eligible numbers at the start of each season and weekly triggers by weather station. The main calibration source. +- Social Security Scotland's Winter Heating Payment statistics: Phase 3 calibration. +- Met Office, [UK climate historic stations](https://www.metoffice.gov.uk/research/climate/maps-and-data/historic-station-data): Phase 2 regional expected-period parameterisation. + +The FRS gives `region` cleanly, so the geographic gate is no extra +data work for Phases 1 and 2. + +## Open questions + +- The qualifying-benefit list is narrower than for the Warm Home + Discount in England and Wales, which now covers everyone on + Universal Credit, Housing Benefit, income-related ESA or Pension + Credit. Model the eligibility as one composite variable, shared with + the Scottish Winter Heating Payment, or as separate flags by route + (Universal Credit, legacy benefits, Pension Credit)? +- Spending is small next to most modelled benefits, but the payment + matters to the low-income pensioners and disabled households it + targets. Phase 1 is worth doing even if Phase 2 doesn't follow. + +## References + +- [The Social Fund Cold Weather Payments (General) Regulations 1988 (SI 1988/1724)][cwp-1988]. +- gov.uk, [Cold Weather Payment](https://www.gov.uk/cold-weather-payment). +- DWP, [Cold Weather Payment statistics](https://www.gov.uk/government/collections/social-fund-cold-weather-payments). +- Scottish equivalent: [The Winter Heating Assistance (Low Income) (Scotland) Regulations 2023 (SSI 2023/16)](https://www.legislation.gov.uk/ssi/2023/16/contents); [mygov.scot](https://www.mygov.scot/winter-heating-payment). +- Northern Ireland: [nidirect, Cold Weather Payment](https://www.nidirect.gov.uk/articles/cold-weather-payment). +- Issue: [#435](https://github.com/PolicyEngine/policyengine-uk/issues/435). + +[cwp-1988]: https://www.legislation.gov.uk/uksi/1988/1724 diff --git a/docs/book/programs/gov/dwp/pip-points-plan.md b/docs/book/programs/gov/dwp/pip-points-plan.md new file mode 100644 index 0000000000..33afd2ef86 --- /dev/null +++ b/docs/book/programs/gov/dwp/pip-points-plan.md @@ -0,0 +1,171 @@ +# PIP point system (planned) + +```{warning} +**Not yet modelled.** PolicyEngine UK currently takes the PIP daily-living +and mobility **band** as a categorical input (`pip_dl_category` and +`pip_m_category`, each `STANDARD | ENHANCED | NONE`) and multiplies by +the published weekly rate. The **points-system** logic that determines +which band a claimant falls into (ten daily-living activities and two +mobility activities, with descriptors worth 0 to 12 points under the +legislation) is **not** modelled. +This page captures the proposed scope, tracked under +[#1069](https://github.com/PolicyEngine/policyengine-uk/issues/1069). +``` + +```{admonition} Status, October 2026 +A first step is open as +[#1661](https://github.com/PolicyEngine/policyengine-uk/pull/1661): total +points inputs (`pip_dl_points`, `pip_m_points`) and the 8- and 12-point +thresholds under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, +with the category derived from points only when it is not given as an +input. That is a simpler form of Phase 1 below: no per-activity scores +and no `use_point_system` switch, since a category input already takes +precedence over the formula. + +PR #1661 is waiting on a data-side path. Its companion, +[policyengine-uk-data #408](https://github.com/PolicyEngine/policyengine-uk-data/pull/408) +(points imputed from the observed categories), was closed in June 2026 +in the uk-data archive clean-up, with PIP points derivation to move into +Microcosm UK's source stages +([microcosm #145](https://github.com/PolicyEngine/microcosm/issues/145)) +once the input variables exist here. +``` + +## What PIP scoring actually is + +The Personal Independence Payment is awarded on a **points-based** +assessment defined in Schedule 1 to [SI 2013/377][si-377] (the Social +Security (Personal Independence Payment) Regulations 2013): + +- **Part 2: Daily living activities**: ten activities (preparing food, + taking nutrition, managing therapy, washing and bathing, managing + toilet needs, dressing and undressing, communicating verbally, + reading and understanding signs and symbols, engaging with other + people face-to-face, making budgeting decisions). Each activity has + a set of descriptors with point values from 0 up to 12; the top + value varies by activity. +- **Part 3: Mobility activities**: two activities (planning and + following a journey, moving around), each with descriptors worth up + to 12 points. + +The award rule: + +| Total points (per component) | Award | +|-----------------------------|-------| +| 0–7 | None | +| 8–11 | Standard rate | +| 12+ | Enhanced rate | + +Daily living and mobility are scored independently, so a claimant can be +on standard daily-living and enhanced mobility, or any other +combination. + +## Why the current categorical model is reasonable for microsim + +For population-aggregate simulation, the categorical bands are +sufficient because: + +- the FRS doesn't capture activity-level point scores at all, +- DWP's published caseload reports band counts (standard / enhanced) + rather than activity scores, +- the data (both the certified Enhanced FRS from policyengine-uk-data + and Microcosm UK, which is being built to replace it) sets + `pip_dl_category` and `pip_m_category` by comparing the weekly PIP daily-living and mobility + amounts respondents report in the FRS with the standard and enhanced + rates. + +So for headline distributional and fiscal analysis the categorical +inputs are correct. + +## Where the point system would matter + +Three reform scenarios that the categorical model can't answer: + +1. **Activity-level descriptor reform**: for example, the April 2024 + green paper consulted on changing the descriptors, and the March 2025 *Pathways to + Work* green paper proposed requiring at least four points in one + daily-living activity for the daily-living component. The government + withdrew the four-point change in July 2025, pending a review of the + PIP assessment. A categorical input can't capture + this; the categorical band wouldn't shift unless an analyst manually + re-bands a synthetic distribution. +2. **Threshold reform**: changing the band thresholds (for example raising + the standard-rate floor from 8 to 10 points). Same limitation. +3. **Targeted analysis by descriptor**: distributional questions like + "how does the enhanced cohort split between mobility activities" need + the underlying activity scores. + +## Proposed scope + +### Phase 1: points-to-category mapping + +- Add the threshold parameters under `gov/dwp/pip/`: + - `daily_living/standard_threshold.yaml` (8) + - `daily_living/enhanced_threshold.yaml` (12) + - `mobility/standard_threshold.yaml` (8) + - `mobility/enhanced_threshold.yaml` (12) +- Add per-activity score inputs as `pip_dl_activity_1_score` ... + `pip_dl_activity_10_score`, `pip_m_activity_1_score`, + `pip_m_activity_2_score`. Type: integer (Person, YEAR), default 0. +- Add derived variables `pip_dl_total_score` and `pip_m_total_score` + summing the activity scores. +- Add `pip_dl_category_from_points` and `pip_m_category_from_points` + that look up the band from the total score against the threshold + parameters. +- Keep `pip_dl_category` / `pip_m_category` as the **input** the rest of + the model reads, with a new switch parameter + `gov.dwp.pip.use_point_system` (default `false`) that, when `true`, + overrides the categorical input with the points-derived band. + +This is a strict superset of the current model: the categorical input +remains authoritative by default, and analysts opting into the points +machinery (for reform analysis) get the activity-level surface. + +### Phase 2: descriptor-level parameters + +- Per-activity descriptor tables under `gov/dwp/pip/descriptors/`, + encoding the point value of each descriptor in SI 2013/377. The + representation can be flat (one YAML per activity with a list of + `(descriptor_id, points)` entries). +- A `pip_dl_activity_1_descriptor` (etc.) enum input that selects which + descriptor applies; the activity score is then a parameter lookup. +- Reform-side: descriptor point values are then parameters that can be + perturbed for "what if descriptor X scored 4 instead of 6" analyses. + +### Phase 3: data-side imputation + +- The FRS provides PIP payment amounts but not descriptor selections. + The data (Microcosm UK, as the replacement dataset) would need to + impute descriptor selections per claimant that are consistent with the observed band. The plausible + approach is to draw uniformly across descriptor combinations that + sum to the observed band, weighted by DWP-published "activities most + commonly scoring above zero" tables. +- This phase is the heaviest and is only required to make Phase 2 + reforms work on the calibrated microsim. For the household + calculator, users can input activity scores directly. + +## Open questions + +- Should the score inputs default to `None` (sentinel) or 0? Sentinel + is cleaner when the points machinery is opt-in via the + `use_point_system` flag. +- DWP's PIP review process distinguishes "ongoing" vs "fixed-term" + awards. Should the model expose the award duration, or treat all + current awards as ongoing? (Current model assumes ongoing.) +- The outcome of the PIP assessment review may change descriptors or + the scoring rule; the descriptor-parameter design in Phase 2 should + make such changes a parameter edit. + +## References + +- [The Social Security (Personal Independence Payment) Regulations 2013 (SI 2013/377)][si-377]: Schedule 1, Parts 2 and 3 define the point system. +- DWP, [Personal Independence Payment: Assessment guide][assess-guide]. +- DWP, [PIP statistics](https://www.gov.uk/government/collections/personal-independence-payment-statistics: caseload by component and band. +- DWP, [Modernising support for independent living: the health and disability green paper (April 2024)][green-paper]: proposals on the descriptors. +- DWP, [Pathways to Work: Reforming Benefits and Support to Get Britain Working (March 2025)](https://www.gov.uk/government/consultations/pathways-to-work-reforming-benefits-and-support-to-get-britain-working-green-paper): the four-point proposal. +- Issue: [#1069](https://github.com/PolicyEngine/policyengine-uk/issues/1069). +- Existing model: `pip_dl_category`, `pip_m_category`, `pip_dl`, `pip_m`. + +[si-377]: https://www.legislation.gov.uk/uksi/2013/377 +[assess-guide]: https://www.gov.uk/government/publications/personal-independence-payment-assessment-guide-for-assessment-providers +[green-paper]: https://www.gov.uk/government/consultations/modernising-support-for-independent-living-the-health-and-disability-green-paper diff --git a/docs/book/programs/gov/hmrc/firm-vat.md b/docs/book/programs/gov/hmrc/firm-vat.md new file mode 100644 index 0000000000..9ae9e79977 --- /dev/null +++ b/docs/book/programs/gov/hmrc/firm-vat.md @@ -0,0 +1,203 @@ +# Firm-level VAT (planned) + +```{warning} +**Not yet modelled.** PolicyEngine UK models VAT only on *household +consumption*: the `vat` variable applies `gov.hmrc.vat.standard_rate` and +`gov.hmrc.vat.reduced_rate` to each household's standard- and reduced-rate +spending (`full_rate_vat_consumption`, `reduced_rate_vat_consumption`), then +grosses the result up to VAT receipts. There is no business side. This page +sets out the scope for adding firm-level VAT and a `Firm` entity, tracked in +[#1320](https://github.com/PolicyEngine/policyengine-uk/issues/1320) and +[#1333](https://github.com/PolicyEngine/policyengine-uk/issues/1333). Both +issues are still open (October 2026). +``` + +## Why firm-level VAT? + +Household-side VAT in PolicyEngine answers the question "what does this +household pay in VAT on the goods and services it buys?" but it can't +answer policy questions framed at the business side: + +- How does changing the VAT registration threshold (£90,000 of taxable + turnover since 1 April 2024) affect the number of firms in scope, and through + them the consumer prices that households face? +- What's the revenue effect of moving a supply between standard, reduced + and zero rates seen from the *supplier* side (input/output netting)? +- How does the flat-rate scheme distort the modelled compliance picture? + +Closing those questions needs a separate entity that *carries* turnover, +sector, and registration status, and variables for input/output VAT and +net liability. + +## Scope + +### Phase 1: minimum viable product + +1. **`Firm` entity** with the bare attributes needed for the threshold + and rate questions: `firm_turnover`, `firm_industry_sector`, + `firm_vat_registered`. Firms are linked to households for sole + traders and partnerships (unincorporated business income flows + already exist in the FRS) and stand alone for incorporated entities. +2. **Registration threshold logic**: a firm must register if its taxable + turnover is more than a new `gov.hmrc.vat.firm.registration_threshold` + parameter, or it can register voluntarily. The threshold has been + £90,000 since 1 April 2024. +3. **Standard / reduced / zero-rated supply split** at the firm level, + driven by sector-specific share parameters. +4. **`firm_vat_on_sales`** (output VAT) and **`firm_vat_on_purchases`** + (input VAT), each computed from share-based decompositions of + turnover and intermediate consumption. +5. **`firm_net_vat_liability`** = output VAT − input VAT. + +### Phase 2: distributional refinements + +- **Flat-rate scheme** for small businesses (a new + `gov.hmrc.vat.firm.flat_rate_scheme` parameter tree). +- **VAT groups** and partial exemption (de minimis rules for + exempt-versus-taxable splits). +- **Cross-border VAT** after Brexit (Northern Ireland under the Windsor + Framework, postponed VAT accounting on imports). + +### Phase 3: incidence + +- **Pass-through** of VAT rate changes to consumer prices, joining up + the household-side `vat` variable and the firm-side + `firm_net_vat_liability` through an explicit incidence assumption + (already modelled in spirit via `vat_change` on the household side; + Phase 3 promotes it to a first-class link). + +## Implementation plan + +### Entity + +A new `Firm` entity in `policyengine_uk/entities.py` next to the +existing `Household`, `BenUnit`, `Person`: + +```python +Firm = build_entity( + key="firm", + plural="firms", + label="Firm", + doc="A business entity that may register for VAT.", + roles=[ + { + "key": "owner", + "plural": "owners", + "label": "Owner", + "doc": "Person or household that owns the firm.", + } + ], + containing_entities=["state"], +) +``` + +The `containing_entities` set deliberately does *not* include +`household` because incorporated firms have no household; the link for +sole traders is via an explicit `firm_owning_household_id` input +variable rather than entity nesting. + +### Variables + +Under a new `variables/gov/hmrc/vat/firm/` tree: + +- `firm_turnover` (input) +- `firm_industry_sector` (input, enum) +- `firm_vat_registered` (derived from turnover and the threshold; can + also be set as input for voluntary registration) +- `firm_standard_rated_share`, `firm_reduced_rated_share`, + `firm_zero_rated_share`, `firm_exempt_share` (derived from sector or + input override) +- `firm_vat_on_sales` (output VAT) +- `firm_vat_on_purchases` (input VAT) +- `firm_net_vat_liability` (output − input) + +The household-side `vat` variable stays untouched; the two sides only +meet in Phase 3. + +### Parameters + +A new tree under `parameters/gov/hmrc/vat/firm/`: + +- `registration_threshold.yaml`: £90,000 from 1 April 2024. +- `deregistration_threshold.yaml`: £88,000 from 1 April 2024. +- Rates: reference the existing `gov/hmrc/vat/standard_rate.yaml` and + `reduced_rate.yaml` rather than duplicating them. Zero-rated supplies + need no rate parameter. +- `sector_supply_split/`: share parameters by industry sector for the + standard/reduced/zero/exempt split (Phase 1.5 refinement; MVP can use + a single national split per sector). +- `flat_rate_scheme/`: Phase 2. + +### Tests + +- Unit tests around the threshold (firm just below, just above, just + above the deregistration threshold). +- Tests of rate-split arithmetic for each sector. +- Integration test asserting that summing `firm_net_vat_liability` + across a representative firm panel reproduces published HMRC VAT + receipts to within calibration tolerance. + +## Data needs + +This is the harder half of the project. Summary of where each Phase 1 +component sits against our current data: + +| Component | Public source | FRS coverage | Datalab source | +|-----------|---------------|--------------|----------------| +| Firm turnover distribution | ONS *Business Demography* | sole-trader / partnership turnover via FRS self-employment income | HMRC Datalab: anonymised VAT returns | +| Industry sector | SIC codes from ONS *Inter-Departmental Business Register (IDBR)* | not in FRS | HMRC Datalab | +| VAT registration share | HMRC *VAT statistics* (annual) | not in FRS | HMRC Datalab | +| Input VAT shares | ONS *Supply and Use tables* | not applicable | HMRC Datalab | + +### Proposed imputation approach + +1. Build a **synthetic firm panel** weighted to ONS Business Demography + counts by SIC sector and turnover band. +2. Attach to each firm a registration status, supply split, and + intermediate-consumption ratio drawn from HMRC VAT statistics and + ONS Supply and Use tables. +3. Calibrate the panel so that summed `firm_net_vat_liability` matches + the HMRC VAT receipts outturn for the year. This mirrors the + second-stage imputation pattern discussed in + [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +4. For sole traders and partnerships, link the synthetic firm row back + to the FRS household that owns it via self-employment income. + +The companion repo [`policyengine/uk-vatlab`][vatlab] hosts the analysis +files (#1333) used to size each step before they are productionised. + +## Effort estimate + +- Adding the `Firm` entity, `firm_turnover` input, threshold logic, and + a single-rate liability variable: ~1–2 days. +- Phase 1 MVP (full Firm entity, sector-driven supply split, calibrated + synthetic firm panel, threshold/rate tests, integration test against + HMRC outturn): ~3–4 weeks, with most time on the data side. +- Phase 2 (flat-rate scheme, VAT groups, partial exemption): another + 2–3 weeks once the panel is stable. +- Phase 3 (cross-side incidence): tied to the + `consumer_incident_tax_revenue_change` infrastructure already in the + repo; the link itself is small but the elasticity assumptions need + agreement. + +## Open questions + +- Should the synthetic firm panel live in the data pipeline or in this + repo? Recommendation: in the data pipeline + ([Microcosm UK](https://github.com/PolicyEngine/microcosm)), with the + rules variables here. +- Do we need to model Making Tax Digital filing-frequency mechanics, or + is annualised liability sufficient for nowcasting purposes? +- HMRC Datalab access for anonymised VAT returns: pursue, or stick to + ONS / public-aggregate calibration? + +## References + +- HMRC, [VAT rates](https://www.gov.uk/vat-rates) and [VAT registration thresholds](https://www.gov.uk/vat-registration-thresholds). +- [Value Added Tax Act 1994](https://www.legislation.gov.uk/ukpga/1994/23/contents): primary statute. +- HMRC, [VAT statistics](https://www.gov.uk/government/statistics/value-added-tax-vat-annual-statistics): annual outturn used for calibration. +- ONS, [UK Business: Activity, Size and Location](https://www.ons.gov.uk/businessindustryandtrade/business/activitysizeandlocation) (the IDBR-based business demography release). +- ONS, [Supply and Use tables](https://www.ons.gov.uk/economy/nationalaccounts/supplyandusetables): intermediate consumption shares. +- Companion analysis repo: [`policyengine/uk-vatlab`][vatlab] (#1333). + +[vatlab]: https://github.com/policyengine/uk-vatlab diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md new file mode 100644 index 0000000000..bf7c3a7f07 --- /dev/null +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -0,0 +1,124 @@ +# Pension tax relief + +This page documents how PolicyEngine UK models tax relief on personal +pension contributions and addresses the confusion reported in +[#704](https://github.com/PolicyEngine/policyengine-uk/issues/704) +about whether higher-rate taxpayers receive the right amount of +relief. #704 is still open (October 2026); the figures below were re-run +on policyengine-uk 2.126.12. + +## What the model does + +`pension_contributions_relief` (defined in +[`variables/gov/hmrc/pensions/pension_contributions_relief.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py)) +reduces the person's taxable income by their pension contributions, +limited to: + +- the person's employment plus self-employment income; +- then the higher of `pension_annual_allowance` and + `gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount` (£3,600); +- and only for people under `gov.hmrc.pensions.pension_contributions_relief_age_limit` + (75). + +The law limits relief to the higher of relevant UK earnings and £3,600 +gross ([Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190)). +The model applies the earnings limit first, so someone with no earnings gets +no relief. That makes no difference to their income tax, which is zero +anyway, but it means the basic-rate top-up paid into their pension under +relief at source is not modelled. + +`pension_annual_allowance` is the standard annual allowance (£60,000 from +6 April 2023), tapered by £1 for every £2 of adjusted net income above +£260,000 down to a minimum of £10,000 (parameters under +`gov.hmrc.income_tax.allowances.annual_allowance`). The model tapers on +`adjusted_net_income`, which stands in for the statutory "adjusted +income". The money purchase annual allowance is not modelled. + +Contributions **above** the annual allowance trigger a separate charge, +`personal_pension_contributions_tax`, which taxes the excess at the +person's marginal rate and is added to income tax. The amount tested +against the allowance is `pension_contributions_for_annual_allowance`, +which counts employee, personal, employer and salary sacrifice +contributions +([Finance Act 2004 s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233)). + +One gap: the relief does not reduce `adjusted_net_income`, so contributions +do not restore personal allowance lost above £100,000, as they do in law +([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)). +An employee on £110,000 keeps a £7,570 personal allowance in 2025-26 whether +or not they contribute £10,000. + +So the headline behaviour is: **within the annual allowance, pension +contributions reduce taxable income £1-for-£1**. The income-tax saving +that the model reports is the contribution × the person's marginal +rate. + +## Worked examples + +The four scenarios reported in #704 (all 2025-26, all self-employed, +single person aged 40, no other income, contributions entered as +`personal_pension_contributions`). The "model" column was re-run in +October 2026 on policyengine-uk 2.126.12: + +| ID | Self-employed income | Pension contribution | Expected income-tax change | What the model returns | Why | +|-------|---------------------|----------------------|-----------------------------|------------------------|-----| +| 11800 | £30,000 | £0 | baseline | baseline | – | +| 11799 | £30,000 | £3,000 | -£600 | -£600 | £3k contribution × 20% basic rate | +| 11817 | £60,000 | £0 | baseline | baseline | – | +| 11816 | £60,000 | £6,000 | -£2,400 | -£2,400 | £6k contribution × 40% higher rate | + +This is exactly the behaviour reported. The user's expectation that +higher-rate cases should only see -£1,200 (£6k × 20%) corresponds to a +misunderstanding of how UK pension tax relief works in aggregate: the +total tax relief is full marginal rate, even though it is paid in two +parts in the real world (relief at source plus higher-rate top-up via +Self Assessment). + +## Relief at source vs marginal-rate relief + +The user-facing UK pension framework has two parallel mechanisms that +add up to the same total relief: + +1. **Relief at source (RAS)**: the pension provider claims the + basic-rate portion (20%) directly from HMRC. The contributor pays + from net income and the pension grows by the gross amount. For a + basic-rate taxpayer, this is the *entire* relief. +2. **Higher-rate top-up**: for taxpayers at higher or additional + rates, the extra band difference (20% for higher rate, 25% for + additional) is reclaimed via Self Assessment. The mechanism is + technically a **basic-rate-band extension**: HMRC widens the basic + rate band by the gross contribution, pushing income that would + otherwise be taxed at the higher rate down into the basic band. + +PolicyEngine computes the **total** relief (both mechanisms together) +because it operates on annual aggregates rather than walking the +cash-flow steps. That matches HMRC's published statistics on the +*fiscal cost of pension tax relief*, which also report the total +across both mechanisms. + +If you want to see the user-visible tax bill reduction separately from +the pension-provider's RAS claim, you'd need to split the existing +relief into two output variables (a basic-rate-portion and a +higher-rate-top-up portion). This is a presentation question rather +than a calculation question: the totals are unchanged. + +## Marginal tax rate display + +#704 also reported a 0% marginal tax rate where 20% or 40% was +expected. The model's `marginal_tax_rate` variable +([`variables/household/marginal_tax_rate.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/marginal_tax_rate.py)) +raises each adult's `employment_income` by +`gov.simulation.marginal_tax_rate_delta` (£1,000, or 0.1% of employment +income where that is larger) and measures the share of the rise that +does not reach `household_net_income`. Re-run in October 2026, it returns +20% for both £30,000 cases and 40% for both £60,000 cases, so the 0% +reported in #704 no longer reproduces in the model. + +## References + +- HMRC, [Tax on your private pension contributions: Tax relief](https://www.gov.uk/tax-on-your-private-pension/pension-tax-relief): user-facing guide to relief at source and higher-rate relief. +- [Finance Act 2004 s. 188-194](https://www.legislation.gov.uk/ukpga/2004/12/section/188): primary statute for relief at source. +- [Finance Act 2004 s. 227](https://www.legislation.gov.uk/ukpga/2004/12/section/227): annual allowance excess charge. +- HMRC, [Personal pensions statistics](https://www.gov.uk/government/collections/personal-pensions-statistics): including the published cost of pension tax relief. +- [Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190): the earnings-or-£3,600 limit on relief. +- Model variables: [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). diff --git a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md new file mode 100644 index 0000000000..39f18ab016 --- /dev/null +++ b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md @@ -0,0 +1,96 @@ +# Pension treatment compared with the Scottish Tax-Benefit Model + +This page compares how PolicyEngine UK and the +[Scottish Tax-Benefit Model (STBM)][stbm] treat personal pension +contributions in income tax, for +[#672](https://github.com/PolicyEngine/policyengine-uk/issues/672) (still open, +October 2026). + +The STBM column is taken from its source code at commit +[`f534ca1`][stbm-it] (`src/IncomeTaxCalculations.jl`, +`calculate_pension_taxation!`, and `src/Pensions.jl`). The +[STBM blog post on pension contributions][stbm-blog] that #672 links to is a +short note, and its author says they are unsure of it. It does not describe +the method, so this page does not rely on it. + +[How PolicyEngine UK models pension tax relief](./pension-tax-relief.md) has +more detail on the PolicyEngine side. + +## Summary + +| | PolicyEngine UK | STBM | +|---|---|---| +| How relief is given | Reduces taxable income by the contribution (`pension_contributions_relief`) | Treats contributions as paid net of basic rate: grosses them up by 1/(1 − basic rate) and widens every rate band above the starting band by the gross amount, as under relief at source | +| Contributions that get relief | The person's own contributions | Employee contributions, additional voluntary contributions **and employer contributions** | +| Basic-rate relief added to the pot | Not shown separately | Recorded separately (`pension_relief_at_source`) | +| Earnings limit | Relief capped at employment plus self-employment income | No earnings cap; £3,600 limit when total income is below it | +| Annual allowance | £60,000, tapered on adjusted net income above £260,000 (`pension_annual_allowance`) | Tapered on total income above a threshold-income parameter | +| Contributions above the allowance | Charged at the marginal rate (`personal_pension_contributions_tax`) | Relief stops at the allowance; no separate charge | +| Employer contributions in the data | Read from the data (`employer_pension_contributions`) | Imputed when missing from the FRS, using the ONS ASHE employer-contribution bands (`src/Pensions.jl`) | +| Carry-forward of unused allowance | Not modelled | Not modelled | + +## Where the two models differ + +### 1. Reducing taxable income versus widening the bands + +For an income-tax payer whose contribution stays inside one band, the two +methods give the same total relief. They can differ when the person pays +little or no income tax: STBM still records the basic-rate addition to the +pot, which PolicyEngine UK does not show. + +**Shared gap: the personal allowance taper.** In law, gross pension +contributions reduce adjusted net income +([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)), +so contributing can restore personal allowance lost above £100,000. +Neither model does this. In PolicyEngine UK, `adjusted_net_income` adds up +the taxable income components without deducting `pension_contributions_relief`: +an employee on £110,000 has adjusted net income of £110,000 and a personal +allowance of £7,570 in 2025-26 whether or not they contribute £10,000. +STBM widens the bands but leaves the taper income unchanged, and its code +flags this as unchecked. + +### 2. Employer contributions + +STBM counts employer contributions in the relief calculation. In UK law +employer contributions are not taxed as the employee's income, so they get +no personal relief. They are simply outside taxable pay, which is how +PolicyEngine UK treats them. Including them in STBM's relief widens the +employee's bands further than the law does. + +### 3. Annual allowance taper + +The legal taper uses "adjusted income", which adds employer contributions, +and applies only when "threshold income" is also above £200,000. + +- PolicyEngine UK tapers on adjusted net income, with no threshold-income + test. +- STBM tapers on total income above a threshold parameter. + +Neither follows the statute exactly. Both affect only very high earners. + +### 4. Lifetime and lump sum allowances + +The lifetime allowance was abolished from 6 April 2024 and replaced by the +lump sum allowance and the lump sum and death benefit allowance. +PolicyEngine UK has no parameters for any of these. They apply when pensions +are drawn, not to contributions. + +## What neither model does + +- Carry-forward of unused annual allowance from the previous three years. +- Behavioural responses: contributions are held fixed when tax rates change. +- Decumulation: crystallisation, the tax-free lump sum, and annuity versus + drawdown choices. + +## References + +- STBM source: [`IncomeTaxCalculations.jl`][stbm-it] and [`Pensions.jl`][stbm-pen] at `f534ca1`. +- [STBM blog: pension contributions][stbm-blog]. +- PolicyEngine UK: [pension tax relief](./pension-tax-relief.md); variables [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py), [`pension_annual_allowance`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). +- HMRC, [Pensions Tax Manual: annual allowance taper](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm057100). +- Issue: [#672](https://github.com/PolicyEngine/policyengine-uk/issues/672). + +[stbm]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl +[stbm-it]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl/blob/f534ca1277260a6b4f24c9ab5ebfe9c015e1484d/src/IncomeTaxCalculations.jl +[stbm-pen]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl/blob/f534ca1277260a6b4f24c9ab5ebfe9c015e1484d/src/Pensions.jl +[stbm-blog]: https://stb-blog.virtual-worlds.scot/articles/2022/01/01/pension-contributions.html diff --git a/docs/book/programs/gov/ofgem/warm-home-discount-plan.md b/docs/book/programs/gov/ofgem/warm-home-discount-plan.md new file mode 100644 index 0000000000..151871ce26 --- /dev/null +++ b/docs/book/programs/gov/ofgem/warm-home-discount-plan.md @@ -0,0 +1,136 @@ +# Warm Home Discount (planned) + +```{warning} +**Not yet modelled.** The Warm Home Discount (WHD) is a £150 rebate off +a household's electricity bill each winter, paid by energy suppliers +under schemes that Ofgem administers. PolicyEngine UK doesn't model it. +This page sets out the proposed scope, tracked in +[#502](https://github.com/PolicyEngine/policyengine-uk/issues/502). +Updated October 2026: in England and Wales eligibility is now based on +benefit receipt alone, which makes the scheme much easier to model than +when this page was first drafted. +``` + +## The scheme now + +### England and Wales + +[The Warm Home Discount (England and Wales) Regulations 2026](https://www.legislation.gov.uk/uksi/2026/389/contents) +(SI 2026/389) continue the scheme from 1 April 2026 to 31 March 2031. +They replace the +[2022 Regulations](https://www.legislation.gov.uk/uksi/2022/772/contents) +(SI 2022/772), which applied to both England and Wales. + +Under the 2022 Regulations, the means-tested group had to pass a +"high cost to heat" test based on property data. For winter 2025-26 +that test was removed, so every household on a qualifying means-tested +benefit became eligible. The government estimated this would bring +2.7 million more households into the scheme, taking it to more than +6 million households in Great Britain +([government response, June 2025](https://assets.publishing.service.gov.uk/media/6852e6e9679778c74ec15e82/expanding-the-warm-home-discount-scheme-2025-to-2026-government-response.pdf)). + +For winter 2026-27, GOV.UK +([eligibility in England and Wales](https://www.gov.uk/the-warm-home-discount-scheme/if-you-live-in-england-and-wales)) +says a household qualifies if, on the qualifying date of 23 August +2026, its electricity supplier is in the scheme, the person's (or +their partner's) name is on the electricity bill, and the person or +their partner gets one of: + +- Universal Credit; +- Housing Benefit; +- income-related Employment and Support Allowance; +- Pension Credit. + +The rebate is applied automatically. + +### Scotland + +The scheme runs under separate Scottish regulations. +[The Warm Home Discount (Scotland) Regulations 2026](https://www.legislation.gov.uk/uksi/2026/488/contents) +(SI 2026/488) came into force on 1 May 2026 and run the scheme to +31 March 2031, replacing the +[2022 Regulations](https://www.legislation.gov.uk/uksi/2022/1073/contents) +(SI 2022/1073). Both 2026 Regulations set the rebate at £150. For winter 2026-27 +([eligibility in Scotland](https://www.gov.uk/the-warm-home-discount-scheme/if-you-live-in-scotland)): + +- **Core group, automatic:** Pension Credit; Universal Credit with an + extra amount for a disability or health condition; income-related ESA + with a disability or pensioner premium; or a Support for Mortgage + Interest loan with one of those premiums. Also people responsible for + a child under 5 who are unemployed and on Universal Credit or + income-related ESA, or have a Support for Mortgage Interest loan. +- **Broader group, by application** to the energy supplier, which can + set its own extra criteria. + +The qualifying date is also 23 August 2026. + +### Northern Ireland + +The scheme doesn't operate in Northern Ireland: its primary legislation +extends to Great Britain only. + +## Proposed scope + +### Phase 1: England and Wales + +Now a rule on benefit receipt: + +- Parameter `gov.ofgem.warm_home_discount.amount`: £150, the + "prescribed rebate" in both sets of 2026 Regulations (£140 before + winter 2022-23). +- Parameter listing the qualifying benefits, so a reform can widen or + narrow the list. +- Variable `warm_home_discount` (Household, YEAR): the amount if the + household is in England or Wales and any benefit unit receives a + qualifying benefit. Read receipt from the modelled benefits, for + example `universal_credit`, `housing_benefit`, `esa_income` and + `pension_credit`, so that take-up carries through. +- No separate take-up flag, since the rebate is paid without a claim. + +### Phase 2: Scotland core group + +The core group can be built from the same benefit variables plus the +Universal Credit disability elements, the ESA premiums and the age of +the youngest child. + +### Phase 3: Scotland broader group + +Application-based, so it needs a take-up rate calibrated to Ofgem's +published recipient numbers for Scotland, on top of the eligible group. + +## Data and calibration + +- Ofgem's [Warm Home Discount pages](https://www.ofgem.gov.uk/environmental-and-social-schemes/warm-home-discount-whd): + recipients and spend by group and country. +- DWP benefit caseloads, already used to calibrate the qualifying + benefits. +- The 2025-26 impact assessment and government response give the + expected number of recipients after the expansion. Use them to check + Phase 1's aggregate. + +## Open questions + +- **Timing.** The scheme year runs from October to March, with the + qualifying date in August. A model year (April to March) contains one + winter's rebate, so pay the rebate in the year that contains the + winter. +- **Who counts as the household.** The rebate attaches to an + electricity account. In multi-benefit-unit households, decide whether + one qualifying benefit unit is enough (simplest, and likely right for + most households) and say so. +- **Electricity VAT interaction.** The rebate reduces the bill. If the + model ever nets it off energy spending, it would interact with the + domestic electricity VAT work in + [#2189](https://github.com/PolicyEngine/policyengine-uk/pull/2189). + Modelled as a benefit, it doesn't. + +## References + +- [The Warm Home Discount (England and Wales) Regulations 2026 (SI 2026/389)](https://www.legislation.gov.uk/uksi/2026/389/contents). +- [The Warm Home Discount (England and Wales) Regulations 2022 (SI 2022/772)](https://www.legislation.gov.uk/uksi/2022/772/contents). +- [The Warm Home Discount (Scotland) Regulations 2026 (SI 2026/488)](https://www.legislation.gov.uk/uksi/2026/488/contents). +- [The Warm Home Discount (Scotland) Regulations 2022 (SI 2022/1073)](https://www.legislation.gov.uk/uksi/2022/1073/contents). +- DESNZ, [Expanding the Warm Home Discount scheme, 2025 to 2026: government response](https://assets.publishing.service.gov.uk/media/6852e6e9679778c74ec15e82/expanding-the-warm-home-discount-scheme-2025-to-2026-government-response.pdf). +- GOV.UK, [Warm Home Discount Scheme](https://www.gov.uk/the-warm-home-discount-scheme). +- Ofgem, [Warm Home Discount scheme pages and reports](https://www.ofgem.gov.uk/environmental-and-social-schemes/warm-home-discount-whd). +- Issue: [#502](https://github.com/PolicyEngine/policyengine-uk/issues/502). diff --git a/docs/book/validation/child-poverty-tcl.md b/docs/book/validation/child-poverty-tcl.md new file mode 100644 index 0000000000..8948ebf2bd --- /dev/null +++ b/docs/book/validation/child-poverty-tcl.md @@ -0,0 +1,120 @@ +# Child poverty validation: removing the two-child limit (2029-30) + +This page compares PolicyEngine UK's estimate of the child-poverty +impact and fiscal cost of removing the [Universal Credit two-child +limit][gov-2cl] against the published estimates from the Department for +Work and Pensions, the Office for Budget Responsibility and the Resolution +Foundation. Budget 2025 announced the removal with effect from 6 April +2026, which the +`gov.dwp.universal_credit.elements.child.limit.child_count` parameter +encodes (`.inf` from `2026-04-06`). + +Tracks [#1398](https://github.com/PolicyEngine/policyengine-uk/issues/1398). + +## Results + +| Source | Children lifted from poverty (2029-30) | Treasury cost (2029-30) | +|--------|----------------------------------------:|------------------------:| +| [DWP poverty impact assessment][gov-2cl] (children); [Budget 2025, Table 1.2][budget-2025] (cost, OBR-certified) | 450,000 (AHC); 300,000 (BHC) | £3.1 bn | +| [Resolution Foundation *No half measures*][rf-nhm] | 480,000 (AHC) | £3.5 bn | +| **PolicyEngine UK, Microcosm UK 2024-25 (AHC)** | **~463,000** | **£3.26 bn** | +| **PolicyEngine UK, Microcosm UK 2024-25 (BHC)** | **~336,000** | (same) | +| PolicyEngine UK, Enhanced FRS 2022-23 (AHC), May 2026 | ~501,000 | £3.40 bn | +| PolicyEngine UK, Enhanced FRS 2022-23 (BHC), May 2026 | ~415,000 | (same) | + +The October 2026 run uses the Microcosm UK 2024-25 national release +(SHA-256 `aa31bdf6…`) on policyengine-uk 2.123.7. On that dataset, +PolicyEngine's after-housing-costs headcount sits between the DWP and +Resolution Foundation figures, its before-housing-costs headcount is +above DWP's, and its cost sits between the Budget 2025 and Resolution +Foundation figures. The May 2026 Enhanced FRS run, kept for comparison, +was above both on headcount. + +## How the estimate was produced + +```python +from policyengine_uk import Microsimulation + +# Local path to the Microcosm UK 2024-25 national release file. +DATASET = "microcosm_uk_2024_25.h5" +YEAR = 2029 + +# Current law baseline (TCL removed from 2026-04-06 per Budget 2025) +sim_baseline = Microsimulation(dataset=DATASET) + +# Counterfactual where the TCL is held in place at 2 children +sim_counter = Microsimulation( + dataset=DATASET, + reform={ + "gov.dwp.universal_credit.elements.child.limit.child_count": { + f"{YEAR}-01-01": 2, + }, + }, +) + +is_child = sim_baseline.calculate("is_child", YEAR) +in_poverty_baseline = sim_baseline.calculate( + "in_poverty_ahc", YEAR, map_to="person" +).astype(float) +in_poverty_counter = sim_counter.calculate( + "in_poverty_ahc", YEAR, map_to="person" +).astype(float) + +children_lifted = ((in_poverty_counter - in_poverty_baseline) * is_child).sum() +fiscal_cost = ( + sim_counter.calculate("gov_balance", YEAR).sum() + - sim_baseline.calculate("gov_balance", YEAR).sum() +) + +print(f"AHC children lifted: {children_lifted / 1e3:.0f}k") +print(f"Treasury cost: £{fiscal_cost / 1e9:.2f}bn") +``` + +This is a *reverse* comparison: PolicyEngine's baseline already includes +the TCL removal (the parameter flips to infinity from 2026-04-06), so the +counterfactual *re-imposes* the limit at 2 children to compute the +2029-30 effect. The signs cancel out: the children-lifted figure is +positive (baseline has fewer in poverty than counterfactual) and the +Treasury cost is positive (baseline collects less revenue than the +counterfactual would). + +## Why the estimates differ + +Three plausible drivers of the remaining differences, and of the move +between the Enhanced FRS and Microcosm runs: + +1. **Nowcasting methodology**. RF and DWP/OBR use somewhat different + earnings, rent, and benefit-uprating paths through 2029-30. See the + [PolicyEngine vs RF comparison](../assumptions/nowcasting-comparison.md) + and the [RF methodology detail](../assumptions/rf-nowcasting-methodology.md) + for the specific divergence points. +2. **Take-up assumptions**. PolicyEngine applies the same Universal + Credit take-up (`would_claim_uc`, set in the data) to families gaining + entitlement from the removal as to existing claimants. A source that + assumes lower take-up among newly entitled families would show a + smaller headcount and cost. +3. **Data and calibration vintage**. The DWP, OBR and RF estimates were + finalised against November 2025 assumptions; PolicyEngine's parameters tick + over with each EFO release. The Microcosm release is built from FRS + 2024-25 rather than 2022-23, with take-up anchored on reported receipt. + +## How to keep this validation alive + +- Re-run the snippet on each new Microcosm UK release, and when the + remaining `would_claim_*` conversions (#1621) land. Both could move the + AHC poverty rate by a non-trivial amount. +- After each new EFO release, update the per-year poverty headcounts and + the fiscal cost table. + +## References + +- HM Treasury, [Budget 2025][budget-2025], Table 1.2: the OBR-certified cost of removing the two-child limit (£3.1 billion in 2029-30). +- OBR, [Economic and Fiscal Outlook November 2025][obr-efo]. +- Resolution Foundation, [No half measures: ending the two-child limit][rf-nhm]. +- DWP, [Removing the two-child limit on Universal Credit: impact on low income poverty levels in the United Kingdom][gov-2cl], Tables 1 and 2 (financial year ending 2030). +- Issue: [#1398](https://github.com/PolicyEngine/policyengine-uk/issues/1398). Related: [#1171](https://github.com/PolicyEngine/policyengine-uk/issues/1171), [#1388](https://github.com/PolicyEngine/policyengine-uk/issues/1388). + +[obr-efo]: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ +[budget-2025]: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html +[rf-nhm]: https://www.resolutionfoundation.org/publications/no-half-measures/ +[gov-2cl]: https://www.gov.uk/government/publications/poverty-impacts-of-social-security-changes-at-budget-2025/removing-the-two-child-limit-on-universal-credit-impact-on-low-income-poverty-levels-in-the-united-kingdom diff --git a/docs/book/validation/policyengine-py-household-impact.md b/docs/book/validation/policyengine-py-household-impact.md new file mode 100644 index 0000000000..1926c110cc --- /dev/null +++ b/docs/book/validation/policyengine-py-household-impact.md @@ -0,0 +1,119 @@ +# Household-impact tests returning 0 (resolved) + +```{note} +**Resolved.** [#1628](https://github.com/PolicyEngine/policyengine-uk/issues/1628) +reported that two `policyengine.py` household-impact integration tests +started asserting `0.0` for expected-positive tax and child-benefit +values after bumping the UK extra to `policyengine-uk==2.88.0`. This +page documents the diagnosis from the PolicyEngine UK side and the +guidance for the upstream `policyengine.py` repo. It is not a model +bug: the UK model produces the expected non-zero values when called +correctly. + +Re-checked in October 2026 on policyengine-uk 2.123.7: both scenarios +below still return £7,486 and £1,354.60. The upstream reproducer +(policyengine.py#280) was closed without merging. policyengine.py has +since replaced the `TestUKHouseholdImpact` tests with +`TestUKCalculateHousehold` in `tests/test_household_impact.py`, which +passes an explicit `year` to every case and pins policyengine-uk +2.125.1. #1628 can be closed once those tests are confirmed green. +``` + +## Reproduction from the UK side + +Running the two scenarios directly against `policyengine-uk` main: + +```python +from policyengine_uk import Simulation + +YEAR = 2025 + +# Scenario 1: single adult with employment income. +s1 = { + "people": {"adult": {"age": {YEAR: 30}, "employment_income": {YEAR: 50_000}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": {"household": {"members": ["adult"]}}, +} +sim = Simulation(situation=s1) +print("income_tax:", sim.calculate("income_tax", YEAR).sum()) +# -> 7486.00 + +# Scenario 2: couple with one child. +s2 = { + "people": { + "a1": {"age": {YEAR: 35}, "employment_income": {YEAR: 30_000}}, + "a2": {"age": {YEAR: 33}}, + "c1": {"age": {YEAR: 5}}, + }, + "benunits": {"benunit": {"members": ["a1", "a2", "c1"]}}, + "households": {"household": {"members": ["a1", "a2", "c1"]}}, +} +sim = Simulation(situation=s2) +print("child_benefit:", sim.calculate("child_benefit", YEAR).sum()) +# -> 1354.60 +``` + +Both return the expected non-zero values across the range 2022-2025 +(the income-tax figure is invariant because the personal allowance is +£12,570 throughout this window). So **the underlying model is healthy**. + +## What changed between 2.74 and 2.88 + +The change in that window that most plausibly explains the upstream +failure is the **dataset-required** invariant, added in 2.76.0 +("Stop defaulting to private dataset"): `Simulation()` with no +`situation=`, no explicit `dataset=` and no +`POLICYENGINE_UK_DEFAULT_DATASET` environment variable raises +`ValueError: Simulation() requires an explicit dataset when no +situation is provided`. If the `policyengine.py` harness was catching +that error and falling through to a degenerate code path (a +zero-filled `Microsimulation`), the tests would see zeros. + +The **default-period** contract is a second thing to rule out, although +it did not change in that window (both 2.74.0 and 2.88.0 default to +2025): + +```python +# policyengine_uk/simulation.py +class Simulation(CoreSimulation): + default_input_period: int = 2025 + default_calculation_period: int = 2025 +``` + +The failing tests built 2024 households. Any calculation that relied on +the default period would compute 2025, and any tax or benefit input +populated only for 2024 would be missing for that year. + +## Guidance for the upstream fix + +The `policyengine.py` household-impact harness should: + +1. Pass an **explicit `period`** to every `calculate(...)` call + matching the year encoded in the situation YAML, rather than relying + on the model default. +2. Build single-household cases with **`Simulation(situation=...)`** + rather than a dataset-backed simulation. They don't need a dataset + and shouldn't bring in the default-dataset env-var dependency. +3. **Assert against the year encoded in the test fixture**, not the + model's `default_input_period`. The fixture-encoded year is what the + test author intended. + +The two failing tests should produce ~£7,486 income tax for a +£50k-earning single adult and ~£1,354.60 of child benefit for a +couple-with-one-child benefit unit at 2025 rates. + +## Why this is documented in `policyengine-uk` rather than fixed there + +The actual fix lives in `policyengine.py`'s test harness and is +explicitly out of scope for this repo (sister-repo edits aren't +made from `policyengine-uk`). This page exists so the next reviewer +who sees `#1628` and is unfamiliar with the policyengine.py setup has +a one-stop diagnosis instead of having to re-derive it. + +## References + +- Issue: [#1628](https://github.com/PolicyEngine/policyengine-uk/issues/1628). +- Reproducer: [`PolicyEngine/policyengine.py#280`](https://github.com/PolicyEngine/policyengine.py/pull/280). +- Default-period contract: [`policyengine_uk/simulation.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/simulation.py) (`default_input_period`, `default_calculation_period`). +- Default-dataset env var contract: `POLICYENGINE_UK_DEFAULT_DATASET`, + defined in [`policyengine_uk/tax_benefit_system.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/tax_benefit_system.py). diff --git a/docs/book/validation/reform-impacts-dashboard-plan.md b/docs/book/validation/reform-impacts-dashboard-plan.md new file mode 100644 index 0000000000..316ace197c --- /dev/null +++ b/docs/book/validation/reform-impacts-dashboard-plan.md @@ -0,0 +1,174 @@ +# Reform impacts dashboard (planned) + +```{note} +**Planning page.** Tracks +[#806](https://github.com/PolicyEngine/policyengine-uk/issues/806), +which asks for a dashboard tracking the budgetary impact of +high-importance reforms over time and across model / data versions. +The repo already has the **fixtures**; what's missing is a published +view onto them. Updated October 2026: still not built, and the planned +dataset move to Microcosm UK (below) makes it more useful. +``` + +## What exists today + +[`policyengine_uk/tests/microsimulation/reforms_config.yaml`](../../../policyengine_uk/tests/microsimulation/reforms_config.yaml) +is a list of canonical reform-impact regression fixtures with the +shape: + +```yaml +- name: Raise basic rate by 1pp + expected_impact: 7.8 # £bn fiscal impact at the reference year + parameters: + gov.hmrc.income_tax.rates.uk[0].rate: 0.21 +- name: Reduce Universal Credit taper rate to 20% + expected_impact: -18.1 + tolerance: 3.0 + parameters: + gov.dwp.universal_credit.means_test.reduction_rate: 0.2 +``` + +Each fixture is run by +[`tests/microsimulation/test_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/test_reform_impacts.py), +which compares the change in `gov_balance` in 2029 with +`expected_impact` (£bn), within `tolerance` (default £1bn). The tests +are marked `microsimulation`, so they run in CI's test suite only when a +dataset is available: `policyengine_uk/tests/conftest.py` points +`POLICYENGINE_UK_DEFAULT_DATASET` at the Enhanced FRS 2023-24 file +(`enhanced_frs_2023_24.h5@1.40.3`) when a Hugging Face token is set. +The suite requires that data in the merge queue and on main, and skips +these tests on pull requests without credentials. So CI already +catches *unintended* impact changes before they reach main. But the +signal is only +pass or fail, and when a change is intended, someone reruns +[`update_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/update_reform_impacts.py) +and the old value is overwritten. The fixtures are regression values +taken from the model's own output, not external costings (the file says +so at the top). + +What's missing is a **time series**: the same eight reforms run on +every commit / version, plotted against the published OBR/RF +benchmark for the same reform, with annotations for the relevant +infrastructure changes (model versions, dataset refreshes, EFO +updates). + +## Proposed shape + +### Storage + +Each scheduled run writes a row per fixture to a CSV / Parquet on +HuggingFace Hub or a small Postgres instance: + +``` +timestamp, policyengine_uk_version, policyengine_uk_data_version, +dataset_url, reform_name, expected_impact, observed_impact, gap +``` + +The table is append-only, with no overwrites, so the history survives. + +### Computation + +A new entry point under `tests/microsimulation/` (or, better, a +GitHub Actions workflow that doesn't run inside the test suite) +walks `reforms_config.yaml`, runs each reform, and writes a row +per fixture. Schedule: weekly + on every release tag. + +### Publication + +Three publication paths, in order of effort: + +1. **Static page in the docs site**: the simplest. Convert the CSV + to a Plotly chart (matching the existing `validation/*.ipynb` + pattern) and render it under `docs/book/validation/`. Updates + when the docs site rebuilds. +2. **Living JSON endpoint**: push the latest aggregate JSON to a + stable URL (HuggingFace dataset or a `gh-pages` artefact) so the + policyengine.org app can render it without needing the docs build. +3. **policyengine.org dashboard tile**: a tile on the marketing site + that shows the latest impact alongside the OBR/RF benchmark. Most + work; right answer once the static page proves the data is stable. + +### What to put on the dashboard + +For each reform: + +- **Latest observed impact** (£bn, current model). +- **Published external benchmark** (OBR EFO costing where one + exists; RF *No half measures*-style costing as a comparator). Annotate + the publication date. +- **Time series** of observed impact across the last N model releases. +- **Annotations** for each known regime shift: EFO release, dataset + refresh, methodology PR. The reforms_config history is the natural + source of "what changed" annotations. + +## Initial reform list + +Use the existing `reforms_config.yaml` entries as the v1 dashboard +content: + +1. Raise basic rate by 1pp. +2. Raise higher rate by 1pp. +3. Raise the personal allowance to £13,000 (the fixture's name says + "by ~800GBP/year", but the allowance is frozen at £12,570 to + 2030-31, so in 2029 the rise is £430). +4. Set the child benefit rate for each additional child to £25 a week. +5. Reduce Universal Credit taper to 20%. +6. Raise Class 1 main employee NI rate to 10%. +7. Raise VAT standard rate by 2pp. +8. Raise additional rate by 3pp. + +These cover three of the four big revenue heads (income tax, NI, VAT) +plus a UC parameter that matters disproportionately for distributional +analysis. + +Add to v2 over time as published OBR / RF reforms become available +benchmarks: + +- Remove the two-child limit (an external comparison is drafted in + [#1724](https://github.com/PolicyEngine/policyengine-uk/pull/1724)). +- Freeze the Personal Allowance for an additional year. +- 1pp employer NI change. + +## Why now + +The UK data pipeline is moving from the Enhanced FRS to +[Microcosm UK](https://github.com/PolicyEngine/microcosm), while the +fixtures still run on the Enhanced FRS 2023-24. Moving the +fixtures' dataset will shift every expected impact at once, and model +changes to VAT, fuel duty and other bases are landing at the same time. +A history that records the dataset alongside each value is the easiest +way to show which change moved which number. + +## What this doesn't replace + +This is a **regression-tracking** dashboard, not a forecast. The +numbers it plots are PolicyEngine UK's reading of a reform under +current model + data + EFO assumptions; they will move when any of +those move, by design. The whole point of plotting the time series is +to make those movements legible rather than hidden. + +For comparison with external nowcasters (RF, IFS, OBR scoring), the +relevant pages are: + +- [nowcasting-comparison.md](../assumptions/nowcasting-comparison.md) (PolicyEngine vs RF). +- [rf-nowcasting-methodology.md](../assumptions/rf-nowcasting-methodology.md) (RF methodology detail). +- [#1724](https://github.com/PolicyEngine/policyengine-uk/pull/1724) (child poverty against OBR and Resolution Foundation benchmarks, in review). + +## Open questions + +- Storage substrate: HuggingFace dataset CSV vs a small managed + database. CSV is simplest; database lets the policyengine.org app + query directly. +- Scheduling: every commit to main, every weekly tag, or both? + Recommend weekly + on tag, since per-commit runs cost + microsim time without proportional information gain. +- Should the dashboard include reform variants from the policyengine + blog series, or stick to the parametric fixtures? Recommend + parametric fixtures only for v1 to keep maintenance burden low. + +## References + +- Issue: [#806](https://github.com/PolicyEngine/policyengine-uk/issues/806). +- Existing fixture list: [`reforms_config.yaml`](../../../policyengine_uk/tests/microsimulation/reforms_config.yaml). +- Existing regression runner: [`test_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/test_reform_impacts.py). +- Script that rewrites the expected values: [`update_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/update_reform_impacts.py). diff --git a/docs/book/validation/ubuntu-vs-macos.md b/docs/book/validation/ubuntu-vs-macos.md new file mode 100644 index 0000000000..8f2d867ad2 --- /dev/null +++ b/docs/book/validation/ubuntu-vs-macos.md @@ -0,0 +1,98 @@ +# Ubuntu vs macOS reproducibility + +```{note} +Issue [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209) +reported that PolicyEngine UK produced different aggregates on +`ubuntu-latest` and `macos-latest` for the same dataset and the same +`policyengine-uk` version. The cause was in parameter uprating. As of +October 2026 the parameter tree is byte-identical across macOS and +Linux, and CI has moved back to Linux. This page records the diagnosis, +the fix and how to check for a regression. +``` + +## Symptom + +The divergence was first spotted in +[#1205](https://github.com/PolicyEngine/policyengine-uk/pull/1205), +whose results differed between the two runners. A diagnostic +repository that ran the same code on both runners and stored the full +entity dataset as artifacts showed that: + +- the input data were identical on both operating systems; +- a limited subset of parameters was uprated on macOS but not on + Ubuntu (the parameter-tree dumps are attached to + [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209#issuecomment-3062418736)). + +The effect on weighted aggregates was small, but it broke +reproducibility. + +## Cause + +[#1223](https://github.com/PolicyEngine/policyengine-uk/pull/1223) +(closed without merging) traced it to an **import-time ordering +problem**: the uprating growth-factor table +(`BASELINE_GROWFACTORS = create_policyengine_uprating_factors_table()`) +was computed when its module was imported, before the parameter tree +had been fully processed. Whether the tree was ready by then depended +on the order in which modules and parameter files were loaded, and that +order differed between the two operating systems. + +## How it was resolved + +- [#1092](https://github.com/PolicyEngine/policyengine-uk/pull/1092) + and [#1207](https://github.com/PolicyEngine/policyengine-uk/pull/1207) + ran the Test job on `macos-latest` in 2025, so that CI matched the + platform that gave the expected results. +- [#1254](https://github.com/PolicyEngine/policyengine-uk/pull/1254) + (multi-year datasets, with uprating as a separate step) removed the + import-time `BASELINE_GROWFACTORS` table. Uprating is now applied by + `apply_uprating` in + [`policyengine_uk/data/economic_assumptions.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/data/economic_assumptions.py). +- Later fixes to the same path: + [#1523](https://github.com/PolicyEngine/policyengine-uk/pull/1523) + (no redundant dataset copy in `apply_uprating`), + [#1543](https://github.com/PolicyEngine/policyengine-uk/pull/1543) and + [#1544](https://github.com/PolicyEngine/policyengine-uk/pull/1544) + (`uprate_rent` lookups). +- policyengine-core now sorts each directory listing when it loads the + parameter tree, so the load order no longer depends on the file + system. + +On 3 October 2026 the Test job moved from macOS to `ubuntu-24.04-arm` +(commit +[`67c63861`](https://github.com/PolicyEngine/policyengine-uk/commit/67c638613)). +That change verified, on main at `3c48247e`, that: + +- the full parameter tree (2,706 parameter objects and 43,666 dated + values, including the baseline copies) is byte-identical on macOS + arm64, `ubuntu-latest` and `ubuntu-24.04-arm`; +- the YAML tests pass on both Linux runners; +- every pytest outcome matches the macOS run of the same code, test by + test. + +#1209 can be closed on that evidence. + +## Checking for a regression + +CI now runs the test suite (`.github/workflows/test-suite.yaml`: the +YAML tests and the Python tests, split over four shards) on Linux arm64 +(`ubuntu-24.04-arm`), and the install and smoke-import checks on x86 +`ubuntu-latest`. There is no macOS job. If an OS difference is +suspected again: + +1. Dump the processed parameter tree on each platform (every parameter + and every dated value, including `parameters.baseline`) and compare + checksums. This is the check the October 2026 move relied on. +2. If the trees differ, look first at anything that runs at import time + or depends on file or dictionary order. +3. If the trees match but results differ, compare the uprated dataset + variables next. #1209 showed the input data themselves were the same + on both systems. + +## References + +- Tracking issue: [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209). +- Where the difference was first seen: [#1205](https://github.com/PolicyEngine/policyengine-uk/pull/1205). +- Diagnosis: [#1223](https://github.com/PolicyEngine/policyengine-uk/pull/1223) (closed). +- CI moved to macOS: #1092, #1207; moved back to Linux: [`67c63861`](https://github.com/PolicyEngine/policyengine-uk/commit/67c638613). +- Uprating rebuild and follow-ups: #1254, #1523, #1543, #1544. From d91b322237c2d7cd7d39b5434b03a1c77ed87900 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:17:14 -0400 Subject: [PATCH 025/141] Measure Pension Credit net earnings as the statute does - Employed earnings (SPC Regs 2002 regs 17(10), 17A): less the income tax they add for the claimant and partner, measured on a branch with nobody's employment income (marginal attribution; methodology ruling d1424), and primary Class 1. - Self-employed earnings (regs 17(11), 17B; CER 1996 regs 13(4), 14): less notional basic-rate tax on profit over personal reliefs, main-rate Class 4 and (before April 2024) Class 2. Pension Credit income replaces the tax and contributions actually payable on the profit with these. - Each source floored at nil (CER reg 13(12)). - Sch VI para 4(1)(a)(vi): working tax credit with a disability element. - HB savings-credit-only route (HB(SPC) Regs reg 27(4)): deduct only the higher HB disregards, since Pension Credit income is now after Sch VI. - Regression cases for savings interactions, pension relief, losses, notional deductions; property tests for statute, flatness under the disregard and monotonicity in either partner's earnings. Co-Authored-By: Claude Opus 5.5 --- changelog.d/pc-earnings-disregard.fixed.md | 2 +- .../higher/disability_benefits.yaml | 8 +- .../housing_benefit_savings_credit_only.yaml | 89 ++++-- .../pension_credit_earnings_disregard.yaml | 272 +++++++++++++++++- .../pension_credit/pension_credit_income.yaml | 8 +- ...on_credit_earnings_disregard_properties.py | 255 +++++++++++----- .../test_pension_credit_income_losses.py | 7 +- ...sing_benefit_savings_credit_only_income.py | 66 +++-- .../pension_credit_earnings_disregard.py | 86 ++++-- .../pension_credit_earnings_income_tax.py | 133 +++++++++ .../pension_credit/pension_credit_income.py | 19 +- ...nsion_credit_self_employment_deductions.py | 67 +++++ 12 files changed, 844 insertions(+), 168 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py diff --git a/changelog.d/pc-earnings-disregard.fixed.md b/changelog.d/pc-earnings-disregard.fixed.md index 8a95e17dbe..53ad66d2e6 100644 --- a/changelog.d/pc-earnings-disregard.fixed.md +++ b/changelog.d/pc-earnings-disregard.fixed.md @@ -1 +1 @@ -Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at earnings. +Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit (including working tax credit with a disability element) or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at net earnings. Employed earnings are net of the income tax they add; self-employed earnings, in Pension Credit income too, are net of the notional tax and contributions of the Computation of Earnings Regulations 1996 reg. 14. The Housing Benefit savings-credit-only route now deducts only the higher Housing Benefit disregards. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml index e2ea6f3328..6aa826b95b 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -8,10 +8,10 @@ description: > the war pensions mobility supplement (v), which has no variable; pension age disability payment (iiia, from 21 October 2024), Scottish adult disability living allowance (iva, from 21 March 2025) and adult disability payment - (viiia, from 21 March 2022), which have no separate variables; and the - working tax credit disability elements (vi), left out because in the model - they follow from DLA or PIP, which are listed, and working tax credit ended - in April 2025. + (viiia, from 21 March 2022), which have no separate variables. Working tax + credit with its disability or severe disability element (vi) is a + benefit-unit award, tested in the formula of + `pension_credit_earnings_disregard`. metadata: label: Pension Credit higher earnings disregard disability benefits unit: list diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml index bc8af60aad..4df1327e70 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml @@ -141,8 +141,9 @@ pension_credit_assessable_capital: [20_000, 0] # 18,000 + 1,040 = 19,040, above the couple minimum guarantee of 18,889. # The non-dependant's is computed but he cannot have Pension Credit: - # 20,000 - 1,486 income tax - 594.40 National Insurance = 17,919.60. - pension_credit_income: [19_040, 17_919.60] + # 20,000 - 1,486 income tax - 594.40 National Insurance - the 5 pound + # earnings disregard (260) = 17,659.60. + pension_credit_income: [19_040, 17_659.60] guarantee_credit: [0, 0] # 1,045.20 - 0.4 x (19,040 - 18,889) = 1,045.20 - 60.40 = 984.80. savings_credit: [984.80, 0] @@ -169,9 +170,8 @@ members: [person1] # £5 a week for a single claimant with earnings (Sch 4 para 7(a)), # the same as the Pension Credit disregard (SPC Regs 2002 Sch VI - # para 5(a)); 5 x 52 = 260. Pension Credit income in the model - # disregards no earnings, so deducting it gives the Secretary of - # State's figure. + # para 5(a)); 5 x 52 = 260. The Secretary of State's figure is + # already after it, and reg 27(4) deducts only higher amounts. housing_benefit_applicable_income_disregard: 260 households: household: @@ -180,14 +180,15 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # 13,000 - 86 = 12,914, as in case 1. - pension_credit_income: 12_914 - savings_credit: 718.62 + # 13,000 - 86 - the 5 pound Pension Credit earnings disregard (260). + pension_credit_income: 12_654 + # 933.82 - 0.4 x (12,654 - 12,376) = 933.82 - 111.20 = 822.62. + savings_credit: 822.62 in_receipt_of_savings_credit_only: true - # 12,914 + 718.62 - 260 = 13,372.62. - housing_benefit_applicable_income: 13_372.62 - # 7,800 - 0.65 x (13,372.62 - 13,312) = 7,800 - 39.40 = 7,760.60. - housing_benefit: 7_760.60 + # 12,654 + 822.62 = 13,476.62; nothing more is disregarded. + housing_benefit_applicable_income: 13_476.62 + # 7,800 - 0.65 x (13,476.62 - 13,312) = 7,800 - 107.00 = 7,693.00. + housing_benefit: 7_693 - name: Case 5, savings credit only lone parent in work, childcare charges and the lone parent and additional earnings disregards are deducted. period: 2026 @@ -196,7 +197,7 @@ people: person1: age: 78 - state_pension: 11_000 + state_pension: 12_500 employment_income: 7_000 # 20 hours a week, so remunerative work (reg 6) and the reg 31 # condition for deducting childcare charges are met. @@ -207,13 +208,10 @@ benunits: benunit: members: [person1, person2] - # Reg 27(4)(c)(i): the higher amount disregarded for a lone parent's - # earnings, £25 a week (Sch 4 para 2) against Pension Credit's £20 - # (SPC Regs 2002 Sch VI para 1); and (d): the additional £17.10 (Sch - # 4 para 9(1)) for a lone parent working 16 hours or more whose net - # earnings cover the disregards and childcare. Pension Credit income - # in the model disregards no earnings, so the whole of both is - # deducted: (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. + # The lone parent's £25 a week (Sch 4 para 2) and the additional + # £17.10 (Sch 4 para 9(1)) for a lone parent working 16 hours or more + # whose net earnings cover the disregards and childcare: + # (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. housing_benefit_applicable_income_disregard: 2_189.20 households: household: @@ -222,20 +220,57 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 = 16,914. - pension_credit_income: 16_914 + # The lone parent's 20 pound Pension Credit disregard (SPC Regs 2002 Sch + # VI para 1): 20 x 52 = 1,040. + pension_credit_earnings_disregard: 1_040 + # (19,500 - 12,570) x 0.2 = 1,386; 19,500 - 1,386 - 1,040 = 17,074. + pension_credit_income: 17_074 # 12,376 + 3,638.96 = 16,014.96. minimum_guarantee: 16_014.96 guarantee_credit: 0 - # 933.82 - 0.4 x (16,914 - 16,014.96) = 933.82 - 359.62 = 574.20. - savings_credit: 574.20 + # 933.82 - 0.4 x (17,074 - 16,014.96) = 933.82 - 423.62 = 510.20. + savings_credit: 510.20 in_receipt_of_savings_credit_only: true # Reg 27(4)(b): 2,600 of childcare (£50 a week), below the weekly # maximum and the earnings. housing_benefit_applicable_income_childcare_element: 2_600 - # 16,914 + 574.20 - 2,600 - 2,189.20 = 12,699. - housing_benefit_savings_credit_only_income: 12_699 - housing_benefit_applicable_income: 12_699 + # Reg 27(4)(c)(i): the higher amount for a lone parent's earnings, + # 1,300 - 1,040 = 260; (d): the additional 17.10 x 52 = 889.20. + # 17,074 + 510.20 - 2,600 - 260 - 889.20 = 13,835. + housing_benefit_savings_credit_only_income: 13_835 + housing_benefit_applicable_income: 13_835 + +- name: Case 5a, the same lone parent with less State Pension, whose 20 pound Pension Credit disregard brings a guarantee credit and its passport. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 78 + state_pension: 11_000 + employment_income: 7_000 + hours_worked: 1_040 + person2: + age: 8 + childcare_expenses: 2_600 + benunits: + benunit: + members: [person1, person2] + housing_benefit_applicable_income_disregard: 2_189.20 + households: + household: + members: [person1, person2] + rent: 7_800 + tenure_type: RENT_FROM_COUNCIL + savings: 0 + output: + # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 - 1,040 = 15,874. + pension_credit_income: 15_874 + # 16,014.96 - 15,874 = 140.96. + guarantee_credit: 140.96 + in_receipt_of_savings_credit_only: false + # The guarantee credit passports the maximum Housing Benefit: the rent. + housing_benefit: 7_800 - name: Case 6, guarantee credit and savings credit together, the award is not savings credit only and the guarantee credit passport applies. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 709c69578e..e5d98d6d72 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -266,9 +266,10 @@ household: members: [claimant] output: - # Income tax (16,040 - 12,570) x 20% = 694. Tax on the earnings is the - # lesser of 694 and 20% x 1,040 = 208, so net earnings are 832; the 20 - # pound (attendance allowance) disregard of 1,040 is capped at 832. + # Income tax (16,040 - 12,570) x 20% = 694, against 486 without the + # earnings (next case), so tax on the earnings is 208 and net earnings are + # 832; the 20 pound (attendance allowance) disregard of 1,040 is capped at + # 832. pension_credit_earnings_disregard: 832 # 16,040 - 694 - 832, the same as with no earnings (next case). pension_credit_income: 14_514 @@ -314,7 +315,7 @@ output: pension_credit_earnings_disregard: 10 * 52 -- name: A Scottish taxpayer's tax on earnings is their top slice on the Scottish schedule, so Pension Credit income does not fall with earnings. +- name: A Scottish taxpayer's tax on earnings is the income tax the earnings add on the Scottish schedule, so Pension Credit income does not fall with earnings. period: 2025 absolute_error_margin: 0.01 input: @@ -405,3 +406,266 @@ members: [claimant] output: pension_credit_earnings_disregard: 20 * 52 + +- name: Tax on earnings includes the tax they add on savings income, so Pension Credit income does not fall with earnings. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Income tax is 1,294 with the earnings and 1,086 without (next case): the + # earnings take 470 of non-savings income over the personal allowance, + # and take the personal allowance off 570 of savings interest. Tax on the + # earnings is 208, so net earnings are 832 and the 20 pound disregard of + # 1,040 is capped there. Pension Credit counts deemed income from capital + # rather than the interest, so its income is 12,000 + 1,040 - 1,294 - 832. + income_tax: 1_294 + pension_credit_employment_income_tax: 208 + pension_credit_earnings_disregard: 832 + pension_credit_income: 10_914 + +- name: The same claimant with savings and no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_tax: 1_086 + pension_credit_earnings_disregard: 0 + # 12,000 - 1,086. + pension_credit_income: 10_914 + +- name: Tax on earnings is net of the pension tax relief the earnings unlock. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + employment_income: 400 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The earnings add 400 x 20% = 80 of tax but let the 300 of contributions + # be relieved, worth 60: income tax is 506, against 486 without them + # (next case), so tax on the earnings is 20. Net earnings are 400 - 20 - + # 300 / 2 = 230, under the 5 pound disregard of 260. + income_tax: 506 + pension_credit_employment_income_tax: 20 + pension_credit_earnings_disregard: 230 + # 15,400 - 506 - 150 - 230: more than without the earnings, so earning + # more never raises Pension Credit. + pension_credit_income: 14_514 + +- name: The same claimant with no earnings. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_tax: 486 + pension_credit_earnings_disregard: 0 + # 15,000 - 486 - 300 / 2. + pension_credit_income: 14_364 + +- name: A self-employment loss is not set against wages in the disregard (CER 1996 reg 13(12)). + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 400 + self_employment_income: -8_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Net earnings are the 400 of wages; the 5 pound disregard is 260. + pension_credit_earnings_disregard: 260 + pension_credit_income: 400 - 260 + +- name: Self-employed earnings under the personal allowance carry no notional tax, and the tax they actually cause is not deducted (CER 1996 reg 14; SPC reg 17(11)). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + self_employment_income: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The State Pension uses all but 570 of the personal allowance, so the + # profit causes all of the (15,000 - 12,570) x 20% = 486 of income tax. + income_tax: 486 + pension_credit_self_employment_income_tax: 486 + # Notional tax at the basic rate on the profit less the personal + # allowance alone is nil; Class 4 is not payable over State Pension age. + pension_credit_self_employment_deductions: 0 + pension_credit_earnings_disregard: 260 + # 15,000 - (486 - 486 + 0) - 260. + pension_credit_income: 14_740 + +- name: Profit within the trading allowance is tax-free and fully disregarded up to the 20 pound limit. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + self_employment_income: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The 1,000 trading allowance relieves the profit, so income tax is the + # (15,000 - 12,570) x 20% = 486 on the pensions. + income_tax: 486 + pension_credit_self_employment_income_tax: 0 + pension_credit_earnings_disregard: 1_000 + # 16,000 - 486 - 1,000, the same as with no profit: 15,000 - 486. + pension_credit_income: 14_514 + +- name: A Scottish self-employed earner's notional tax is at the Scottish basic rate, with main-rate Class 4 under State Pension age. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: SCOTLAND + output: + # (20,000 - 12,570) x 20% = 1,486, not the 1,457.73 of income tax on the + # Scottish starter, basic and intermediate bands; Class 4 is + # (20,000 - 12,570) x 6% = 445.80. + income_tax: 1_457.73 + pension_credit_self_employment_deductions: 1_486 + 445.80 + +- name: Before April 2024 the notional contributions include Class 2 (CER 1996 reg 14(2)(a)). + period: 2023 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2 3.45 x 52 = 179.40. + pension_credit_self_employment_deductions: 1_486 + 668.70 + 179.40 + +- name: The notional tax deducts the blind person's allowance with the personal allowance (ITA 2007 Part 3 Chapter 2). + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 20_000 + blind_persons_allowance: 3_070 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570 - 3,070) x 20%. + pension_credit_self_employment_deductions: 872 + +- name: Working tax credit with its disability element qualifies for the 20 pound disregard (Sch VI para 4(1)(a)(vi)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 1_000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disability element counts only with working tax credit in payment. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 0 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index 71853707b6..d98e564feb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -219,6 +219,8 @@ tenure_type: OWNED_OUTRIGHT output: pension_credit_earnings: 10_000 - pension_credit_income: 10_000 - # 238 x 52 = 12,376, less income of 10,000. - pension_credit: 2_376 + # 10,000 less the 5 pound a week earnings disregard (260); the earnings + # are under the personal allowance and the primary threshold. + pension_credit_income: 9_740 + # 238 x 52 = 12,376, less income of 9,740. + pension_credit: 2_636 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index c60d3de0f2..cad0a87ef3 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,37 +1,53 @@ -"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). +"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI) +and of the net earnings they apply to (regs. 17(10), 17(11), 17A and 17B). Invariants, over generated benefit units in England and Scotland: -1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are - the claimant's and partner's gross earnings less earnings NI, half of - pension contributions and income tax on the earnings. The tax on a - person's earnings is measured independently, as the fall in their income - tax when the simulation is rerun without their earnings (the earnings as - the top slice, on their own Scottish or rest-of-UK schedule). w is 20 if - the unit is a lone-parent family, or a claimant or partner is entitled to - carer's allowance (an input here), receives a listed +1. Statute: the disregard equals min(net earnings, 52 x w), recomputed here + from the inputs and independent simulations: + - each person's own earnings, each source floored at nil, less their + primary Class 1 contributions, half of their pension contributions and, + on self-employed profit, the notional tax and contributions of CER 1996 + reg. 14 (basic rate on the profit less the personal allowance and any + marriage allowance; main-rate Class 4 under State Pension age), floored + at nil; + - less the income tax payable on the claimant's and partner's employed + earnings, measured as the fall in their income tax when the household + is simulated again with nobody's employment income. + w is 20 if the unit is a lone-parent family, or a claimant or partner is + entitled to carer's allowance (an input here), receives a listed disability benefit or is blind; otherwise 5 single, 10 couple. A dependent child's DLA or caring never qualifies. 2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). -3. Differential: Pension Credit income is the pre-change income (sources less - tax, NI and half of pension contributions, floored at 0) less the - disregard, floored at 0. +3. Differential: Pension Credit income is the sources less tax, NI and half + of pension contributions, with the tax and contributions actually payable + on self-employed profit replaced by the notional ones, floored at 0, less + the disregard, floored at 0. 4. Monotonicity: the guarantee credit is never below its value without the - disregard. -5. Earning more never raises Pension Credit entitlement (reg 17(9) applies the - disregard to net earnings). -6. The dated benefit lists follow the amending instruments: ESA from 27 + disregard (for units with nobody under State Pension age). +5. Earning more, employed or self-employed, by either partner, never raises + Pension Credit. +6. Under the disregard, Pension Credit income does not change with earnings. +7. The dated benefit lists follow the amending instruments: ESA from 27 October 2008, PIP and AFIP from 8 April 2013. +8. The Scottish basic rate read for the notional tax is 20% in every year, + including those before the starter rate. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation from policyengine_uk.model_api import WEEKS_IN_YEAR YEAR = 2024 +BASIC_RATE = 0.2 +PERSONAL_ALLOWANCE = 12_570 +CLASS_4_MAIN_RATE = 0.06 +LOWER_PROFITS_LIMIT = 12_570 +UPPER_PROFITS_LIMIT = 50_270 +STATE_PENSION_AGE = 66 SETTINGS = settings( max_examples=25, deadline=None, @@ -56,11 +72,16 @@ def units(draw): names = ["claimant"] + (["partner"] if couple else []) for n in names: person = { - "age": draw(st.sampled_from([67, 72, 85])), + "age": draw( + st.sampled_from([67, 72, 85] + ([64] if n == "partner" else [])) + ), "employment_income": draw(st.sampled_from([0, 50, 400, 1_040, 3_000])), - "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), + "self_employment_income": draw( + st.sampled_from([0, 0, 1_500, 15_000, -2_000]) + ), "state_pension": draw(st.integers(min_value=0, max_value=12_000)), "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), + "savings_interest_income": draw(st.sampled_from([0, 0, 3_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), "is_entitled_to_carer_benefit": draw( st.sampled_from([False, False, False, True]) @@ -100,10 +121,13 @@ def simulate(case): ) -def without_earnings(case, name): +def without(case, sources): + """The case with nobody's income from each of ``sources``.""" people = {n: dict(p) for n, p in case["people"].items()} - people[name]["employment_income"] = 0 - people[name]["self_employment_income"] = 0 + for person in people.values(): + for source in sources: + if source in person: + person[source] = 0 return {"people": people, "country": case["country"]} @@ -115,58 +139,100 @@ def person_values(sim, variable): return np.asarray(sim.calculate(variable, YEAR, map_to="person"), dtype=float) +def weekly_amount(people): + adult_people = [p for n, p in people.items() if n != "child"] + couple = len(adult_people) == 2 + disabled = any( + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) + for p in adult_people + ) + carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) + lone_parent = "child" in people and not couple + return 20 if (disabled or carer or lone_parent) else (10 if couple else 5) + + +def notional_deductions(person, marriage_allowance): + """CER 1996 reg. 14 on the person's profit, from the inputs: no + generated adult is blind-person's-allowance relieved, born before 1935 + or liable for Class 2 (abolished in 2024-25).""" + profit = max(0, person.get("self_employment_income", 0)) + if profit == 0: + return 0.0 + tax = BASIC_RATE * max(0, profit - PERSONAL_ALLOWANCE - marriage_allowance) + class_4 = 0.0 + if person["age"] < STATE_PENSION_AGE: + class_4 = CLASS_4_MAIN_RATE * min( + max(0, profit - LOWER_PROFITS_LIMIT), + UPPER_PROFITS_LIMIT - LOWER_PROFITS_LIMIT, + ) + return tax + class_4 + + @SETTINGS @given(units()) def test_statute_bounds_differential_and_monotonicity(case): people = case["people"] sim = simulate(case) names = list(people) - adults = [n != "child" for n in names] - gross = np.array( - [ - people[n]["employment_income"] + people[n].get("self_employment_income", 0) - if n != "child" - else 0 - for n in names - ], + adult = np.array([n != "child" for n in names]) + employment = np.array( + [max(0, people[n].get("employment_income", 0)) for n in names], dtype=float + ) + self_employment = np.array( + [max(0, people[n].get("self_employment_income", 0)) for n in names], dtype=float, ) - ni = sum( - person_values(sim, v) - for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + marriage_allowance = person_values(sim, "marriage_allowance") + notional = np.array( + [ + notional_deductions(people[n], marriage_allowance[i]) + for i, n in enumerate(names) + ] + ) + assert np.allclose( + person_values(sim, "pension_credit_self_employment_deductions"), + notional, + atol=0.01, ) - income_tax = person_values(sim, "income_tax") - tax_on_earnings = np.zeros(len(names)) - for i, name in enumerate(names): - if gross[i] > 0: - rerun = person_values(simulate(without_earnings(case, name)), "income_tax") - tax_on_earnings[i] = max(0.0, income_tax[i] - rerun[i]) + class_1 = person_values(sim, "ni_class_1_employee") contributions = 0.5 * person_values(sim, "pension_contributions") - net = np.maximum(0, gross - ni - tax_on_earnings - contributions) - net_earnings = float(net[adults].sum()) + own_net = np.maximum( + 0, employment + self_employment - class_1 - notional - contributions + ) - adult_people = [people[n] for n in names if n != "child"] - couple = len(adult_people) == 2 - disabled = any( - p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) - for p in adult_people + income_tax = person_values(sim, "income_tax") + no_employment = person_values( + simulate(without(case, ["employment_income"])), "income_tax" ) - carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) - lone_parent = "child" in people and not couple - weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) - expected = min(net_earnings, weekly * WEEKS_IN_YEAR) + no_earnings = person_values( + simulate(without(case, ["employment_income", "self_employment_income"])), + "income_tax", + ) + employment_tax = max(0.0, float((income_tax - no_employment)[adult].sum())) + self_employment_tax = max(0.0, float((no_employment - no_earnings)[adult].sum())) + net_earnings = max(0.0, float(own_net[adult].sum()) - employment_tax) + + expected = min(net_earnings, weekly_amount(people) * WEEKS_IN_YEAR) disregard = unit_value(sim, "pension_credit_earnings_disregard") assert np.isclose(disregard, expected, atol=0.01) - assert 0 <= disregard <= min(gross.sum(), 20 * WEEKS_IN_YEAR) + 0.01 + gross = float((employment + self_employment)[adult].sum()) + assert 0 <= disregard <= min(gross, 20 * WEEKS_IN_YEAR) + 0.01 sources = sim.tax_benefit_system.parameters( f"{YEAR}-06-01" ).gov.dwp.pension_credit.guarantee_credit.income total = sum(float(sim.calculate(s, YEAR, map_to="benunit")[0]) for s in sources) + actual_self_employment = ( + self_employment_tax + + float(person_values(sim, "ni_class_2")[adult].sum()) + + float(person_values(sim, "ni_class_4")[adult].sum()) + ) deductions = ( - float(sim.calculate("income_tax", YEAR, map_to="benunit")[0]) - + float(sim.calculate("national_insurance", YEAR, map_to="benunit")[0]) - + 0.5 * float(sim.calculate("pension_contributions", YEAR, map_to="benunit")[0]) + float(income_tax[adult].sum()) + + float(person_values(sim, "national_insurance")[adult].sum()) + - actual_self_employment + + float(notional[adult].sum()) + + float(contributions[adult].sum()) ) before = max(0.0, total - deductions) assert np.isclose( @@ -174,23 +240,76 @@ def test_statute_bounds_differential_and_monotonicity(case): max(0.0, before - disregard), atol=0.01, ) - gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) - assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + # Mixed-age couples (a partner under State Pension age) get no guarantee + # credit at all, so the comparison is for units entirely over that age. + if all(p["age"] >= STATE_PENSION_AGE for n, p in people.items() if n != "child"): + gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) + assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + + +EARNINGS_RISES = st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 260, 1_040, 5_000]), +) + + +def raised(case, rise): + name, source, extra = rise + if name not in case["people"]: + name = "claimant" + people = {n: dict(p) for n, p in case["people"].items()} + people[name][source] = people[name].get(source, 0) + extra + return {"people": people, "country": case["country"]} @SETTINGS -@given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) -def test_earning_more_never_raises_pension_credit(case, extra): - more = {name: dict(person) for name, person in case["people"].items()} - more["claimant"]["employment_income"] += extra +@given(units(), EARNINGS_RISES) +def test_earning_more_never_raises_pension_credit(case, rise): before = unit_value(simulate(case), "pension_credit_entitlement") - after = unit_value( - simulate({"people": more, "country": case["country"]}), - "pension_credit_entitlement", - ) + after = unit_value(simulate(raised(case, rise)), "pension_credit_entitlement") assert after <= before + 0.01 +@st.composite +def units_under_the_disregard(draw): + """Units with small earnings and no pension contributions or losses, so + the disregard usually covers the net earnings and no floor on a person's + net earnings binds.""" + case = draw(units()) + for person in case["people"].values(): + person.pop("personal_pension_contributions", None) + if "employment_income" in person: + person["employment_income"] = draw(st.sampled_from([0, 50, 100])) + person["self_employment_income"] = draw(st.sampled_from([0, 0, 120])) + return case + + +@settings(SETTINGS, suppress_health_check=list(HealthCheck)) +@given( + units_under_the_disregard(), + st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 100]), + ), +) +def test_pension_credit_income_is_flat_in_earnings_under_the_disregard(case, rise): + sims = [simulate(case), simulate(raised(case, rise))] + cap = weekly_amount(case["people"]) * WEEKS_IN_YEAR + # Where the disregard covers all the net earnings before and after the + # rise, and Pension Credit income is not floored at nil. + assume( + all( + unit_value(sim, "pension_credit_earnings_disregard") < cap - 0.01 + and unit_value(sim, "pension_credit_income") > 0 + for sim in sims + ) + ) + before, after = (unit_value(sim, "pension_credit_income") for sim in sims) + assert np.isclose(before, after, atol=0.05) + + def test_benefit_lists_follow_amending_instruments(): from policyengine_uk.system import system @@ -209,3 +328,11 @@ def lists(date): for date in ["2008-10-26", "2013-04-08", "2026-04-06"]: person, _ = lists(date) assert {"incapacity_benefit", "sda", "attendance_allowance", "dla"} <= person + + +def test_scottish_basic_rate_band_is_twenty_percent_in_every_year(): + from policyengine_uk.system import system + + bands = system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets + for year in range(2016, 2030): + assert bands[1].rate(f"{year}-06-01") == 0.2 diff --git a/policyengine_uk/tests/test_pension_credit_income_losses.py b/policyengine_uk/tests/test_pension_credit_income_losses.py index 94e4b5e535..c6d456d4f2 100644 --- a/policyengine_uk/tests/test_pension_credit_income_losses.py +++ b/policyengine_uk/tests/test_pension_credit_income_losses.py @@ -129,8 +129,9 @@ def test_issue_2255_reproducers(): ) # 20,000 less income tax of 20% of (20,000 - 12,570). assert property_loss == (pytest.approx(18_514, abs=1), 0) - # 238 x 52 = 12,376, less wages of 10,000. + # Wages of 10,000 less the 5 pound a week earnings disregard (SPC Regs + # 2002 Sch VI para 5), 260; 238 x 52 = 12,376, less 9,740. assert trading_loss == ( - pytest.approx(10_000, abs=1), - pytest.approx(2_376, abs=1), + pytest.approx(9_740, abs=1), + pytest.approx(2_636, abs=1), ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py index 74fb57f8e4..09bf1fc13f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py @@ -13,19 +13,14 @@ class housing_benefit_savings_credit_only_income(Variable): "authority takes the Secretary of State's assessment of net income for " "that award and modifies it only for the listed items. Modelled: the " "savings credit payable is added, and childcare charges and the " - "Housing Benefit earnings disregards are deducted. Pension Credit " - "income in the model disregards no earnings, whereas the Secretary of " - "State's figure is after the Pension Credit disregards (£20 for a lone " - "parent, £5 single or £10 couple otherwise). Deducting the Housing " - "Benefit disregards (£25 for a lone parent, £5 or £10 otherwise, and " - "the additional £17.10) therefore gives that figure less the higher " - "Housing Benefit amounts the regulation allows: £5 more for a lone " - "parent, and the additional disregard. This holds while neither " - "programme's £20 disregards for disabled people, carers and some " - "occupations are modelled; for those earners the Secretary of State's " - "figure is after £20, so the omission changes the income assessed, the " - "savings credit payable and possibly whether the award is savings " - "credit only. Childcare charges are deducted as in the general " + "higher Housing Benefit earnings disregards are deducted: a lone " + "parent's 25 pounds over the amount disregarded for Pension Credit, " + "and the additional 17.10 pounds. The Secretary of State's figure is " + "Pension Credit income, which is already after the Pension Credit " + "earnings disregards (20 pounds for a lone parent, carer, disabled " + "or blind earner, otherwise 5 pounds single or 10 pounds couple), so " + "Housing Benefit's own 5 and 10 pound amounts are not deducted again. " + "Childcare charges are deducted as in the general " "route, without the work condition or the cap at earnings. Housing " "Benefit's own income and tariff income rules do not apply to the " "Secretary of State's figure, which already counts Pension Credit " @@ -63,22 +58,35 @@ def formula(benunit, period, parameters): childcare = benunit( "housing_benefit_applicable_income_childcare_element", period ) - # Reg 27(4)(c)(i) (Sch 4 para 2: £25 for a lone parent, against SPC - # Regs 2002 Sch VI para 1's £20) and (d) (Sch 4 para 9(1), the - # additional £17.10; the para 5A exempt work disregard is not - # modelled). NI: reg 25(4)(c)(i) and (d), Sch 5 paras 2 and 9(1). - # The model's Pension Credit income applies no Sch VI disregard, so - # deducting the whole Housing Benefit disregard also takes off the Sch - # VI amounts already inside the Secretary of State's figure: £20 for a - # lone parent (para 1) and £5 or £10 otherwise (para 5), which match - # Sch 4 paras 2 and 7 but for the lone parent's extra £5. Neither - # schedule's £20 disregards (Sch VI paras 2 to 4; Sch 4 paras 3 to 5) - # are modelled. If Sch 4 paras 3 to 5 are added to the disregard - # variable, this route needs the Sch VI grounds instead, as reg 27(4) - # does not let Housing Benefit's own £20 grounds replace them. - # Reg 27(5): Housing Benefit's own income rules, including tariff - # income from capital, do not apply to the Secretary of State's figure. - disregard = benunit("housing_benefit_applicable_income_disregard", period) + # Reg 27(4)(c)(i) and (d) (NI: reg 25(4)(c)(i) and (d)): the + # Secretary of State's figure is after the SPC Regs 2002 Sch VI + # earnings disregards (pension_credit_earnings_disregard), so only the + # higher amounts Housing Benefit allows come off: a lone parent's + # Sch 4 para 2 disregard (25 pounds) over the amount disregarded for + # Pension Credit, and the additional disregard of Sch 4 para 9(1) + # (the para 5A exempt work disregard is not modelled). Housing + # Benefit's other grounds do not replace the Pension Credit ones. This + # route applies only under the pension-age Regulations, where the + # disregard is the standard amount (Sch 4 paras 2 and 7) and the + # additional amount (para 9), with no specified-accommodation amount. + p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard + net_earnings = benunit("housing_benefit_net_earnings", period) + lone_parent = benunit("is_lone_parent", period) + weekly_amount = select( + [lone_parent, benunit("is_couple", period)], + [p.lone_parent, p.couple], + default=p.single, + ) + standard = min_(weekly_amount * WEEKS_IN_YEAR, net_earnings) + additional = max_( + 0, benunit("housing_benefit_applicable_income_disregard", period) - standard + ) + higher_lone_parent = where( + lone_parent, + max_(0, standard - benunit("pension_credit_earnings_disregard", period)), + 0, + ) + disregard = higher_lone_parent + additional return max_( 0, pension_credit_income + savings_credit_payable - childcare - disregard, diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 2e39753ac5..3bbed54e3b 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -8,23 +8,31 @@ class pension_credit_earnings_disregard(Variable): "the State Pension Credit Regulations 2002: 20 pounds a week for a " "lone parent (para. 1), where the claimant or partner is a carer " "satisfying Sch. I para. 4 (para. 3), or where the claimant or partner " - "receives a listed disability benefit or is certified blind " - "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " + "receives a listed disability benefit (including working tax credit " + "with its disability or severe disability element) or is certified " + "blind (para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " "pounds for a couple (para. 5). 20 pounds is the most disregarded " "however many conditions are met (para. 4A). Reg. 17(9) disregards the " "sums 'in calculating the claimant's earnings', which are net of income " "tax and National Insurance (reg. 17(10)) and of half of pension " "contributions (reg. 17A(4A)), so the disregard never exceeds the " - "claimant's and partner's net earnings. Income tax on earnings is the " - "tax on them as the top slice of the person's taxable non-savings " - "income, on the person's own rate schedule (Scottish or rest of UK), " - "capped at their income tax: the tax deducted from the earnings where " - "the personal allowance goes against pension income first (DMG " - "86038-86039), so earning more never raises Pension Credit. Not " - "modelled: the employment-specific 20 pound " + "claimant's and partner's net earnings, each source floored at nil. " + "Employed earnings (regs. 17(10) and 17A) are net of primary Class 1 " + "contributions and of the income tax payable on them " + "(pension_credit_employment_income_tax). Self-employed earnings (regs. " + "17(11) and 17B) are net of the notional tax and contributions of the " + "Computation of Earnings Regulations 1996 reg. 14 " + "(pension_credit_self_employment_deductions). Half of pension " + "contributions come off both. Not modelled: the employment-specific " + "20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " - "protections of para. 4(2) to (4)." + "protections of para. 4(2) to (4), and the continued carer " + "qualification of Sch. I para. 4 after carer's allowance stops (the " + "model has no record of when it stopped). The carer condition reads " + "is_entitled_to_carer_benefit, the carer addition's own test, so the " + "two always agree; the Carer's Allowance earnings limit is applied to " + "that test by PolicyEngine/policyengine-uk#2312." ) entity = BenUnit definition_period = YEAR @@ -34,6 +42,9 @@ class pension_credit_earnings_disregard(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", ) def formula(benunit, period, parameters): @@ -51,9 +62,20 @@ def formula(benunit, period, parameters): if len(unit_disability_benefits) > 0 else False ) + # Para. 4(1)(a)(vi): in receipt of working tax credit that includes + # its disability or severe disability element. + wtc_disability_element = ( + add( + benunit, + period, + ["WTC_disabled_element", "WTC_severely_disabled_element"], + ) + > 0 + ) & (benunit("working_tax_credit", period) > 0) disabled = ( benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit + | wtc_disability_element ) # Sch. I para. 4: a claimant or partner entitled to carer's allowance # (the carer addition's own test), not caring hours alone. @@ -68,30 +90,32 @@ def formula(benunit, period, parameters): p.standard[relation_type], ) - # Net earnings of the claimant and partner (regs. 17(10), 17A(4A)). - gross = max_(0, add(person, period, pc.guarantee_credit.earnings_sources)) - national_insurance = add( - person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - ) - # Tax on the earnings as the top slice of taxable non-savings income. - rates = parameters(period).gov.hmrc.income_tax.rates - taxable = person("earned_taxable_income", period) - below_earnings = max_(0, taxable - min_(gross, taxable)) - scottish = person("pays_scottish_income_tax", period) - top_slice_tax = where( - scottish, - rates.scotland.rates.calc(taxable) - - rates.scotland.rates.calc(below_earnings), - rates.uk.calc(taxable) - rates.uk.calc(below_earnings), - ) - income_tax_on_earnings = min_(person("income_tax", period), top_slice_tax) - pension_contributions = ( + # Net earnings of the claimant and partner, each source floored at + # nil: a loss in one employment is not set against another (CER 1996 + # reg. 13(12), applied by SPC Regs 2002 reg. 17B). + # Employed earners (regs. 17(10) and 17A): less the income tax payable + # on the earnings and the primary Class 1 contributions deducted from + # them. Self-employed earners (regs. 17(11) and 17B): less the + # notional tax and contributions of CER 1996 regs. 13(4) and 14. Both: + # less half of pension contributions (reg. 17A(4A); CER 1996 reg. + # 13(4)(c)). + employment = max_(0, person("employment_income", period)) + self_employment = max_(0, person("self_employment_income", period)) + deductions = add( + person, + period, + ["ni_class_1_employee", "pension_credit_self_employment_deductions"], + ) + ( person("pension_contributions", period) * pc.income.pension_contributions_deduction ) - net = max_( + # Each person's deductions come off their own earnings; the income tax + # on employed earnings is measured for the claimant and partner + # together (pension_credit_employment_income_tax). + own_net = max_(0, employment + self_employment - deductions) + net_earnings = max_( 0, - gross - national_insurance - income_tax_on_earnings - pension_contributions, + benunit.sum(own_net * claimant_or_partner) + - benunit("pension_credit_employment_income_tax", period), ) - net_earnings = benunit.sum(net * claimant_or_partner) return min_(weekly * WEEKS_IN_YEAR, net_earnings) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py new file mode 100644 index 0000000000..565315d514 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -0,0 +1,133 @@ +from policyengine_uk.model_api import * + +DECISION = ( + "PolicyEngine does not model PAYE codes, so which part of a person's " + "income tax is payable on their earnings is a methodology choice, put to " + "Max on PolicyEngine/policyengine-uk#2019; this provisional choice treats " + "earnings as the top slice of the person's income. " +) + + +def income_tax_with_inputs_zeroed(simulation, period, variables): + """Each person's income tax with every person's value of each of + ``variables`` set to nil, on a branch that is dropped afterwards.""" + name = "pension_credit_without_" + "_and_".join(variables) + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + # A branch starts with the values already calculated, so drop them + # before replacing the inputs, as marginal_tax_rate does. + system_variables = simulation.tax_benefit_system.variables + for variable in system_variables: + if ( + variable not in simulation.input_variables + and not system_variables[variable].is_input_variable() + ): + branch.delete_arrays(variable) + people = branch.populations["person"] + for variable in variables: + branch.set_input(variable, period, np.zeros(people.count)) + return people("income_tax", period) + finally: + del simulation.branches[name] + + +class pension_credit_income_tax_without_employment_income(Variable): + value_type = float + entity = Person + label = "Income tax without employment income, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + income_tax = person("income_tax", period) + if not (person("employment_income", period) != 0).any(): + return income_tax + return income_tax_with_inputs_zeroed( + person.simulation, period, ["employment_income"] + ) + + +class pension_credit_income_tax_without_earnings(Variable): + value_type = float + entity = Person + label = "Income tax without earnings, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "or self-employment income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + without_employment = person( + "pension_credit_income_tax_without_employment_income", period + ) + if not (person("self_employment_income", period) != 0).any(): + return without_employment + return income_tax_with_inputs_zeroed( + person.simulation, + period, + ["employment_income", "self_employment_income"], + ) + + +class pension_credit_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax on employed earnings, for Pension Credit" + documentation = ( + "The income tax payable on the claimant's and partner's employed " + "earnings, which SPC Regs 2002 reg. 17(10) disregards in their net " + "amount: the fall in the claimant's and partner's income tax without " + "anyone's employment income, never negative. It includes any tax the " + "earnings add on savings and dividends (a lost starting-rate band or " + "personal savings allowance) and any effect of one partner's earnings " + "on the other's tax (through the marriage allowance), so Pension " + "Credit income does not change with either partner's earnings while " + "the disregard covers them. " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + ) + + def formula(benunit, period, parameters): + person = benunit.members + added = person("income_tax", period) - person( + "pension_credit_income_tax_without_employment_income", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) + + +class pension_credit_self_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax actually payable on self-employed profit, for Pension Credit" + documentation = ( + "The income tax the claimant's and partner's self-employment income " + "adds to their income tax, beneath any employment income: the fall in " + "their income tax without anyone's employment income when nobody has " + "self-employment income either, never negative. Pension Credit income " + "replaces it with the notional tax of the Computation of Earnings " + "Regulations 1996 reg. 14 (SPC Regs 2002 reg. 17(11)). " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17" + + def formula(benunit, period, parameters): + person = benunit.members + added = person( + "pension_credit_income_tax_without_employment_income", period + ) - person("pension_credit_income_tax_without_earnings", period) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index e40999ab55..86e9504e9c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -10,6 +10,7 @@ class pension_credit_income(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/5", "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", ) documentation = ( "Income and deductions of the claimant and partner, together with " @@ -46,10 +47,24 @@ def formula(benunit, period, parameters): pension_contributions = benunit.sum( person("pension_contributions", period) * is_claimant_or_partner ) + # Self-employed earnings are net of the notional tax and contributions + # of the Computation of Earnings Regulations 1996 reg. 14, in place of + # those actually payable on the profit (reg. 17(11)). tax = benunit.sum( - add(person, period, ["income_tax", "national_insurance"]) + ( + add( + person, + period, + [ + "income_tax", + "national_insurance", + "pension_credit_self_employment_deductions", + ], + ) + - add(person, period, ["ni_class_2", "ni_class_4"]) + ) * is_claimant_or_partner - ) + ) - benunit("pension_credit_self_employment_income_tax", period) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate # Schedule VI earnings disregards (reg. 17(9)). diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py new file mode 100644 index 0000000000..252d784f73 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -0,0 +1,67 @@ +from policyengine_uk.model_api import * + + +class pension_credit_self_employment_deductions(Variable): + value_type = float + entity = Person + label = "Notional tax and contributions on self-employed profit, for Pension Credit" + documentation = ( + "The income tax and social security contributions deducted from " + "self-employed earnings for Pension Credit (SPC Regs 2002 regs. 17(11) " + "and 17B; Computation of Earnings Regulations 1996 regs. 13(4) and " + "14), in place of those actually payable on the profit: a notional " + "income tax at the basic rate (the Scottish basic rate for a Scottish " + "taxpayer) on the profit less only the personal reliefs of ITA 2007 " + "Part 3 Chapters 2, 3 and 3A, whatever the person's other income; " + "Class 4 contributions at the main rate on the profit between the " + "lower and upper profits limits, where Class 4 is payable at all; and " + "the Class 2 contributions payable, until reg. 14(2)(a) was omitted on " + "6 April 2024." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + ) + + def formula(person, period, parameters): + profit = max_(0, person("self_employment_income", period)) + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate is the band after the starter rate; read the + # band itself, since the scale at an instant leaves out the starter + # band before 2018-19. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + basic_rate = where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + rates.uk.rates[0], + ) + # Chapter 2 (personal and blind person's allowances) and Chapter 3A + # (marriage allowance, which the model deducts from income) relieve + # income; Chapter 3 (married couple's allowance) reduces the tax. + reliefs = add( + person, + period, + ["personal_allowance", "blind_persons_allowance", "marriage_allowance"], + ) + notional_tax = max_( + 0, + basic_rate * max_(0, profit - reliefs) + - person("married_couples_allowance_deduction", period), + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.thresholds.lower_profits_limit + upper = class_4.thresholds.upper_profits_limit + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.rates.main * min_(max_(0, profit - lower), upper - lower), + 0, + ) + return where( + profit > 0, + notional_tax + notional_class_4 + person("ni_class_2", period), + 0, + ) From e9b20e5d40a1e29ba91570775b118e4be5b682eb Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:50:51 +0200 Subject: [PATCH 026/141] Measure the cap's earned income per claimant and split AFIP by scheme The reg 82 earnings exception now sums each assessed claimant's uc_individual_earned_income_before_mif. Each person's earnings are floored at nil before the couple's are added (UC Regs 2013 reg 57(2)), only tax and NI in respect of employment or self-employment are deducted (reg 55(5)(b), so no tax on private pension income), and contributions paid after 75 are not deducted (FA 2004 s.188(3)(a)). Armed forces independence payment now counts only for a claimant on Universal Credit (reg 2, reg 83(1)(c)), and for the claimant, partner or a young person on Housing Benefit (HB Regs 2006 reg 75F(1)(ea)). Adds regressions for each, with final UC and HB awards, and AFIP to the single-claim property tests. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1818.fixed.md | 4 +- .../benefit_cap/is_benefit_cap_exempt.yaml | 181 +++++++++++++++++- .../test_uc_income_from_capital_properties.py | 12 +- .../tests/test_uc_single_claim_properties.py | 23 ++- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 52 +++-- ...is_benefit_cap_exempt_health_disability.py | 25 ++- 6 files changed, 245 insertions(+), 52 deletions(-) diff --git a/changelog.d/1818.fixed.md b/changelog.d/1818.fixed.md index f43c7ef0de..a4ba29c2be 100644 --- a/changelog.d/1818.fixed.md +++ b/changelog.d/1818.fixed.md @@ -1,4 +1,4 @@ -- Benefit cap earnings exception (Universal Credit Regulations 2013 reg. 82) now deducts only the tax attributable to earnings, rather than the claimant's whole income tax liability, so property, savings, dividend and pension income no longer erode net earnings and wrongly remove the exception. Relievable pension contributions are now deducted as reg. 55(5) requires. +- Benefit cap earnings exception (Universal Credit Regulations 2013 reg. 82) now measures each claimant's own earned income with `uc_individual_earned_income_before_mif`, rather than netting the claimant's whole income tax liability off the couple's combined earnings. Only income tax and National Insurance in respect of employment or self-employment are deducted (reg. 55(5)(b), reg. 57(2)), so tax on pension, property, savings and dividend income no longer removes the exception. Each person's earnings are floored at nil before the couple's are added, so one partner's pension contributions no longer reduce the other's earnings, and contributions paid after 75, which are not relievable (Finance Act 2004 s. 188(3)(a)), are no longer deducted. - Moved the benefit cap earnings threshold out of three hardcoded copies of `10_152` into the dated parameter `gov.dwp.benefit_cap_earnings_exemption`, with values from 2020-21 to 2026-27 (2026-27 rises to £881 a month) derived from 16 hours a week at the National Living Wage. -- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap when the claimant, their partner or a Housing Benefit young person receives it (HB Regs 2006 reg 75F(1)(ea); UC Regs 2013 regs 2 and 83(1)(c)), not a child under 16. The variable has no dataset source, so this changes household calculations only. +- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap, with each scheme's recipients: for a family on Universal Credit only a claimant's AFIP counts (UC Regs 2013 regs 2 and 83(1)(c)); for a family on Housing Benefit the claimant's, their partner's or a young person's (HB Regs 2006 reg 75F(1)(ea)). A child's never does. The variable has no dataset source, so this changes household calculations only. - Removed dead copy-pasted code from the three `is_benefit_cap_exempt_*` variables, which computed exemption conditions that were never returned. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml index c58cb20870..ea522ac501 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml @@ -1,4 +1,5 @@ -# Benefit cap exceptions, Universal Credit Regulations 2013 regs. 82-83. +# Benefit cap exceptions, Universal Credit Regulations 2013 regs. 82-83 and +# Housing Benefit Regulations 2006 reg. 75F. # https://www.legislation.gov.uk/uksi/2013/376/regulation/82 # https://www.gov.uk/benefit-cap/when-youre-not-affected @@ -75,6 +76,115 @@ output: is_benefit_cap_exempt_earnings: false +- name: Tax on private pension income does not reduce earned income for the earnings exception + # Reg. 55(5)(b) deducts only income tax paid "in respect of the + # employment". The 5,000 private pension uses up the personal allowance, + # so earned_income_tax (tax on non-savings, non-dividend income) is 546, + # but none of it is in respect of the 10,300 of wages. Earned income is + # 10,300, which clears 846 x 12 = 10,152, so the cap does not reduce the + # family's Universal Credit. + period: 2025 + input: + people: + parent: + age: 35 + employment_income: 10_300 + private_pension_income: 5_000 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [parent, child1, child2] + households: + household: + members: [parent, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + earned_income_tax: [546, 0, 0] + uc_income_tax_on_earnings: [0, 0, 0] + is_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 33_876.72 + absolute_error_margin: 1 + +- name: One partner's pension contributions do not reduce the other partner's earnings + # Reg. 82(1)(a) adds the couple's earned income, and each person's earned + # income is their own earnings less their own deductions. The + # self-employed partner's 100 of profit less 3,000 of relievable pension + # contributions is nil, not minus 2,900 (reg. 57(2), steps 3 and 4). + # Combined earned income is 10,600, which clears 881 x 12 = 10,572. + period: 2026 + input: + people: + employee: + age: 35 + employment_income: 10_600 + self_employed: + age: 35 + self_employment_income: 100 + personal_pension_contributions: 3_000 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [employee, self_employed, child1, child2] + households: + household: + members: [employee, self_employed, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + uc_individual_earned_income_before_mif: [10_600, 0, 0, 0] + is_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 31_074.08 + absolute_error_margin: 1 + +- name: Pension contributions after 75 are not deducted from earned income + # Contributions paid after the individual reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct them. + # The mixed-age couple's Universal Credit award is capped like any other + # (reg. 3(2)(a); no age exception reaches it), and the older partner's + # 11,000 of earnings clears 881 x 12 = 10,572 before and after the + # 1,000 contribution. + period: 2026 + input: + people: + older_partner: + age: 76 + employment_income: 11_000 + employee_pension_contributions: 1_000 + younger_partner: + age: 40 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [older_partner, younger_partner, child1, child2] + households: + household: + members: [older_partner, younger_partner, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + is_uc_assessed_claimant: [true, true, false, false] + housing_benefit_pension_age_regulations_apply: false + uc_individual_earned_income_before_mif: [11_000, 0, 0, 0] + is_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 42_066.40 + absolute_error_margin: 1 + # --- Earnings threshold is a dated parameter, not a hardcoded constant --- - name: The 2025-26 earnings threshold is 846 a month @@ -168,24 +278,83 @@ is_benefit_cap_exempt_health_disability: false is_benefit_cap_exempt: false -- name: A young person's Armed Forces Independence Payment excepts the benefit unit - # HB Regs 2006 reg 75F(1)(ea) names a young person the claimant is - # responsible for: a Child Benefit qualifying young person (reg 19). +- name: A dependent young person's AFIP does not lift the Universal Credit cap + # For Universal Credit, AFIP is within "attendance allowance" (UC Regs + # 2013 reg 2), and reg 83(1)(c) excepts only where "a claimant is + # receiving" it. The 17-year-old is a qualifying young person, not a + # claimant, so the family's Universal Credit is capped at the London + # family rate of 25,323 a year. + period: 2026 + input: + people: + parent: + age: 45 + young_person: + age: 17 + current_education: UPPER_SECONDARY + armed_forces_independence_payment: 5_740 + child1: + age: 10 + child2: + age: 8 + benunits: + benunit: + members: [parent, young_person, child1, child2] + households: + household: + members: [parent, young_person, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + is_qualifying_young_person_for_child_benefit: [false, true, false, false] + is_uc_assessed_claimant: [true, false, false, false] + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + benefit_cap: 25_323 + universal_credit_pre_benefit_cap: 52_792.44 + benefit_cap_reduction: 30_737.64 + universal_credit: 22_054.80 + absolute_error_margin: 1 + +- name: A dependent young person's AFIP lifts the Housing Benefit cap + # HB Regs 2006 reg 75F(1)(ea) names "the claimant, the claimant's partner + # or a young person for whom the claimant or the claimant's partner is + # responsible". The same family on legacy Housing Benefit (an existing + # award, no Universal Credit claim) is outside the cap, so its Housing + # Benefit is paid in full. Without the AFIP it would be capped by 7,945. period: 2026 input: people: parent: age: 45 + housing_benefit_reported: 10_000 young_person: age: 17 current_education: UPPER_SECONDARY armed_forces_independence_payment: 5_740 + child1: + age: 10 + child2: + age: 8 benunits: benunit: - members: [parent, young_person] + members: [parent, young_person, child1, child2] + would_claim_uc: false + households: + household: + members: [parent, young_person, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 output: - is_qualifying_young_person_for_child_benefit: [false, true] + universal_credit_pre_benefit_cap: 0 is_benefit_cap_exempt_health_disability: true + is_benefit_cap_exempt: true + benefit_cap_reduction: 0 + housing_benefit_pre_benefit_cap: 30_000 + housing_benefit: 30_000 + absolute_error_margin: 1 # --- Other exceptions are unchanged --- diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index fe9c64ca20..cb83e9a805 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -33,12 +33,12 @@ s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is -also unchanged. The cap's earnings exception now nets only the tax on -non-savings, non-dividend income off earnings (PolicyEngine/policyengine-uk#1986), -so savings and dividend income leave it alone, but property income is taxed in -that same band and can still switch the cap on. -test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception pins -the dividend case. +also unchanged. The cap's earnings exception now reads each claimant's +Universal Credit earned income (uc_individual_earned_income_before_mif), which +deducts only the tax on their earnings (uc_income_tax_on_earnings), so +interest, dividends and rent leave it alone too, apart from the allowance +effects above. test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception +pins the dividend case. """ import numpy as np diff --git a/policyengine_uk/tests/test_uc_single_claim_properties.py b/policyengine_uk/tests/test_uc_single_claim_properties.py index 266cd3a93a..6e4b71cb85 100644 --- a/policyengine_uk/tests/test_uc_single_claim_properties.py +++ b/policyengine_uk/tests/test_uc_single_claim_properties.py @@ -115,6 +115,7 @@ "pip_dl": 0.0, "afcs": 0.0, "esa_contrib": 0.0, + "armed_forces_independence_payment": 0.0, } @@ -129,6 +130,7 @@ def adult(draw, ages): pip_dl=draw(pip), afcs=draw(sometimes_paid), esa_contrib=draw(sometimes_paid), + armed_forces_independence_payment=draw(sometimes_paid), ) @@ -157,6 +159,7 @@ def adult_inputs(age, **inputs): pip_dl=0.0, afcs=0.0, esa_contrib=0.0, + armed_forces_independence_payment=0.0, ) values.update(inputs) return values @@ -256,6 +259,16 @@ def example_families(): tenure="RENT_FROM_COUNCIL", region="NORTH_EAST", ), + # An other member with armed forces independence payment and nothing + # else. + dict( + claimant=adult_inputs(40), + partner=adult_inputs(40, armed_forces_independence_payment=5_000.0), + children=[], + rent=0.0, + tenure="RENT_FROM_COUNCIL", + region="NORTH_EAST", + ), ] @@ -269,6 +282,7 @@ def example_families(): afcs_partner=6, esa_partner=7, afcs_claimant=8, + afip_partner=9, ) EXAMPLE_FAMILIES = example_families() @@ -489,12 +503,12 @@ def test_deduction_is_the_joint_claimants(families, year): @given( families=populations, year=st.sampled_from([2021, 2026]), - balances=st.lists(st.floats(0, 30_000), min_size=18, max_size=18), + balances=st.lists(st.floats(0, 30_000), min_size=20, max_size=20), ) @example( families=example_families(), year=2026, - balances=[8_000.0, 16_000.0, 0.0, 30_000.0, 2_000.0, 0.0] + [5_000.0] * 12, + balances=[8_000.0, 16_000.0, 0.0, 30_000.0, 2_000.0, 0.0] + [5_000.0] * 14, ) def test_capital_includes_the_other_members(families, year, balances): families = copy.deepcopy(families) @@ -679,6 +693,11 @@ def test_examples_reach_the_cases(): assert not single["is_benefit_cap_exempt_health_disability"][family] assert joint["is_benefit_cap_exempt_health_disability"][family] assert single["is_benefit_cap_exempt_other"][i["afcs_claimant"]] + # Reg. 83(1)(c) with reg. 2: armed forces independence payment is + # attendance allowance, which lifts the cap only for a claimant. + family = i["afip_partner"] + assert not single["is_benefit_cap_exempt_health_disability"][family] + assert joint["is_benefit_cap_exempt_health_disability"][family] # Reg. 80A(2) and Sch 4 para 28(2): single rate and shared accommodation. family = i["young_renter"] assert single["is_benefit_cap_single_claimant_rate"][family] diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 45019d5150..8df3f0e23f 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -6,40 +6,38 @@ class is_benefit_cap_exempt_earnings(Variable): entity = BenUnit label = "Whether exempt from the benefits cap because of earnings" definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/82" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + "https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ) def formula(benunit, period, parameters): - # Regulation 82 of the Universal Credit Regulations 2013 excepts a - # claimant (or couple, on their combined income) from the benefit cap - # where their earned income reaches 16 hours a week at the National - # Living Wage. Earned income here is employed and self-employed - # earnings net of the income tax, National Insurance and relievable - # pension contributions attributable to those earnings (reg. 55(5), - # reg. 57), not net of total tax on all income. + # Regulation 82(1)(a) of the Universal Credit Regulations 2013 + # excepts an award from the benefit cap where "the claimant's earned + # income or, if the claimant is a member of a couple, the couple's + # combined earned income" reaches 16 hours a week at the National + # Living Wage. A dependant's earnings do not count. # - # Reg. 82(1)(a) tests "the claimant's earned income or, if the - # claimant is a member of a couple, the couple's combined earned - # income": a dependant's earnings do not count. + # Earned income is each claimant's own: their employed earnings less + # their relievable pension contributions and the income tax and + # primary Class 1 contributions paid in respect of the employment + # (reg. 55(5)), and their self-employed earnings after the reg. 57(2) + # steps, which make a loss or a nil result nil. So one partner's + # deductions never come off the other partner's earnings, tax on + # pension, property, savings or dividend income is not deducted, and + # contributions paid after 75 are not relievable (Finance Act 2004 + # s. 188(3)(a)). uc_individual_earned_income_before_mif applies all + # of this per person. It is read before the minimum income floor, + # because reg. 82(4) excludes income treated as earned under reg. 62. # # Only earned income is read, not the Universal Credit award itself, # to avoid a circular dependency with the cap. person = benunit.members claimant = person("is_uc_assessed_claimant", period) - earnings = person("employment_income", period) + person( - "self_employment_income", period + earned_income = benunit.sum( + person("uc_individual_earned_income_before_mif", period) * claimant ) - # earned_income_tax is the tax on non-savings, non-dividend income, - # which excludes property, savings and dividend income. It is the - # closest available measure of tax on earnings; it still includes tax - # on private and state pension income, which is the approximation we - # accept here (households with pension income are generally already - # exempt through the state pension age exception). - deductions = ( - person("earned_income_tax", period) - + person("national_insurance", period) - + person("pension_contributions", period) - ) - net_earnings = max_(0, benunit.sum((earnings - deductions) * claimant)) - monthly_threshold = parameters(period).gov.dwp.benefit_cap_earnings_exemption - return net_earnings >= monthly_threshold * MONTHS_IN_YEAR + return earned_income >= monthly_threshold * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 1e5b132eff..0c287b0a7c 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -55,21 +55,28 @@ def formula(benunit, period, parameters): ) qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - # Armed Forces Independence Payment counts only for the people the - # regulations name. Housing Benefit: "the claimant, the claimant's - # partner or a young person for whom the claimant or the claimant's - # partner is responsible" (HB Regs 2006 reg 75F(1)(ea)), so not a - # child under 16. Universal Credit: AFIP is within "attendance + # Armed Forces Independence Payment counts for different people in + # each scheme. Universal Credit: AFIP is within "attendance # allowance" (UC Regs 2013 reg 2), which excepts only where "a - # claimant is receiving" it (reg 83(1)(c)). The model applies one - # cap to both schemes, so the claimant, partner or a Housing Benefit - # young person counts. + # claimant is receiving" it (reg 83(1)(c)), so a dependant's AFIP + # does not lift the cap. Housing Benefit: "the claimant, the + # claimant's partner or a young person for whom the claimant or the + # claimant's partner is responsible" (HB Regs 2006 reg 75F(1)(ea)), + # so a young person counts but a child under 16 does not. The model + # applies one cap to both schemes; it takes the Universal Credit + # route for a family with a Universal Credit award and the Housing + # Benefit route otherwise. + on_universal_credit = benunit.project( + benunit("universal_credit_pre_benefit_cap", period) > 0 + ) + uc_claimant = person("is_uc_assessed_claimant", period) & ~not_a_claimant housing_benefit_young_person = person( "is_child_or_young_person_for_legacy_benefits", period ) & person("is_qualifying_young_person_for_child_benefit", period) - afip_counted = ( + housing_benefit_person = ( person("is_claimant_or_partner", period) & ~not_a_claimant ) | housing_benefit_young_person + afip_counted = where(on_universal_credit, uc_claimant, housing_benefit_person) afip = ( add_for_members( benunit, period, ["armed_forces_independence_payment"], afip_counted From facdab83d4e68e8169bcdc95bc12328e8fb9b32b Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 14 Oct 2026 16:22:05 +0200 Subject: [PATCH 027/141] Reconcile with main's projection fallback and reg 77 inputs List main's four owned_company inputs under the indices they declare, which the guard caught after the merge. Narrow main's fallback assertion in test_projection_horizon.py to the columns that are still unlisted, and correct the guard docstring now that the uprating attribute drives the post-2030 fallback. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/uprating_indices.yaml | 4 ++++ .../code_health/test_uprating_declarations.py | 24 ++++++++++--------- .../tests/test_projection_horizon.py | 13 +++------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 22297976ed..ee511b60fb 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -127,6 +127,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - rent_paid_as_boarder - rent_paid_as_lodger - stocks_and_shares_isa +- owned_company_capital +- owned_company_holding_value +- owned_company_trade_assets # A formula of benunit_reported_capital; listed so a dataset that stores it # directly is uprated like the input. - uc_reported_capital @@ -134,6 +137,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income - self_employment_gross_receipts - trading_loss +- owned_company_income_share gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index 11931080dd..b4b2572614 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -1,14 +1,15 @@ """Every variable declaring `uprating` should appear in uprating_indices.yaml. -The class-level `uprating = "..."` attribute on a Variable is dead metadata: -nothing in policyengine-uk reads it, and policyengine-core only inspects it to -refuse combining it with its own `@uprated` decorator. `uprating_indices.yaml` -is the only mechanism that applies uprating, via `apply_single_year_uprating` -and `reset_growthfactor_uprating` in `data/economic_assumptions.py`. - -A variable can therefore declare an index and silently never be uprated. That -is what #1859 found for electricity and gas consumption, and #1862 for three -more columns. This guard makes the gap fail loudly at the point it is +Through 2030, `uprating_indices.yaml` is the only mechanism that applies +uprating, via `apply_single_year_uprating` and `reset_growthfactor_uprating` +in `data/economic_assumptions.py`. The class-level `uprating = "..."` +attribute on a Variable is read only by the fallback in +`_unmapped_uprating_indices`, which grows unlisted columns after +LEGACY_PROJECTION_END_YEAR (2030). + +A variable can therefore declare an index and silently stay flat from the +dataset year to 2030. That is what #1859 found for electricity and gas +consumption, and #1862 for three more columns. This guard makes the gap fail loudly at the point it is introduced rather than surfacing in a downstream study. """ @@ -66,8 +67,9 @@ def test_declared_uprating_variables_are_in_the_yaml(): ) assert not missing, ( "these variables declare `uprating` but are absent from " - f"uprating_indices.yaml, so they are never uprated: {missing}. Add " - "them to the matching index block, or to KNOWN_UNUPRATED with a reason." + f"uprating_indices.yaml, so they are not uprated through 2030: " + f"{missing}. Add them to the matching index block, or to " + "KNOWN_UNUPRATED with a reason." ) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 7d6f77375e..a9837a99f9 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -177,20 +177,13 @@ def test_each_singly_indexed_input_grows_by_its_index_every_year(projected): def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected): """Previously unmapped columns keep their old values through 2030, then grow by the same cumulative-index ratio Core used beyond that year. + Columns uprating_indices.yaml now lists (the debts, ISAs, lodger and + boarder rent, gross receipts, salary sacrifice, water bills and service + charges) grow every year instead, so they are no longer fallbacks. """ assert { - "mortgage_debt", - "consumer_debt", - "rent_paid_as_boarder", - "self_employment_gross_receipts", - "rent_paid_as_lodger", "rail_usage", - "stocks_and_shares_isa", - "cash_isa", - "water_and_sewerage_charges", "employee_pension_contributions_reported", - "pension_contributions_via_salary_sacrifice", - "housing_service_charges", } <= set(FALLBACK_INDEX_OF) for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): for variable, index_name in FALLBACK_INDEX_OF.items(): From 6c3f5d60e4947512e1bc9a77b2ff40a252dbcd40 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 14 Oct 2026 16:22:39 +0200 Subject: [PATCH 028/141] Update the changelog fragment for the merge with main Co-Authored-By: Claude Opus 5.5 --- changelog.d/uprate-four-remaining-columns.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/uprate-four-remaining-columns.fixed.md b/changelog.d/uprate-four-remaining-columns.fixed.md index 2deb67266f..d3a71d0683 100644 --- a/changelog.d/uprate-four-remaining-columns.fixed.md +++ b/changelog.d/uprate-four-remaining-columns.fixed.md @@ -1 +1 @@ -- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so were never uprated. Also uprate seven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder` and `rent_paid_as_lodger` (per-capita GDP) and `self_employment_gross_receipts` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. +- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate eleven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. From 54fa62d64d28a976df2ac6cc3ce5e79a38d53a5d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 15 Oct 2026 15:58:14 +0200 Subject: [PATCH 029/141] Stack gains on taxable income when splitting the higher and additional CGT bands The split of gains above the basic rate band gave every gainer the whole higher band (higher rate limit less basic rate limit), never subtracting taxable income already above the basic rate limit. Gains are the top slice of income (TCGA 1992 s. 1H), so the band left for them is the higher rate limit less taxable income or the basic rate limit, whichever is higher. Current law charges the higher and additional CGT rates alike, so only reforms that set them apart change; current-law results move by float32 rounding where gains straddle the new split point. Co-Authored-By: Claude Opus 5.5 --- .../cgt-additional-band-stacking.fixed.md | 1 + .../capital_gains_tax_rate_bands.yaml | 138 ++++++++++++++++++ .../test_capital_gains_tax_components.py | 18 ++- policyengine_uk/tests/test_gift_aid.py | 12 +- .../capital_gains_tax/capital_gains_tax.py | 17 ++- 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 changelog.d/cgt-additional-band-stacking.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml diff --git a/changelog.d/cgt-additional-band-stacking.fixed.md b/changelog.d/cgt-additional-band-stacking.fixed.md new file mode 100644 index 0000000000..fea6956098 --- /dev/null +++ b/changelog.d/cgt-additional-band-stacking.fixed.md @@ -0,0 +1 @@ +Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml new file mode 100644 index 0000000000..fbde35bf2a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml @@ -0,0 +1,138 @@ +# Gains are the top slice of income (TCGA 1992 s. 1H). The model splits gains +# above the basic rate band at the income tax higher rate limit so reforms can +# set an additional CGT rate; the law has none, and the two rates are equal. +# The split stacks gains on top of taxable income, as income tax does: the +# higher band left for gains is the higher rate limit less taxable income or +# the basic rate limit, whichever is higher. +# +# 2025-26: basic rate limit 37,700, higher rate limit 125,140, annual exempt +# amount 3,000, CGT rates 18% and 24%. allowances is set to 0 so taxable income +# equals net_income. + +- name: Additional rate reform, income below the basic rate limit. + # Taxable income 20,000 leaves 17,700 of basic rate band. 150,000 - 3,000 = + # 147,000: 17,700 x 0.18 = 3,186; the higher band left is 125,140 - 37,700 + # = 87,440, x 0.24 = 20,985.60; the rest, 41,860 x 0.45 = 18,837. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 20_000 + allowances: 0 + capital_gains: 150_000 + output: + capital_gains_tax: 43_008.60 + +- name: Additional rate reform, income between the basic and higher rate limits. + # Taxable income 100,000 uses the basic rate band. The higher band left is + # 125,140 - 100,000 = 25,140, x 0.24 = 6,033.60; the rest of the 97,000, + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Additional rate reform, income above the higher rate limit. + # Taxable income 200,000 is above 125,140, so all 47,000 of gains after the + # annual exempt amount are in the additional band: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 + +- name: Higher rate reform, income above the higher rate limit. + # No higher band is left, so a higher CGT rate of 45% does not apply: + # 47,000 x 0.24 at the unchanged additional rate. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Higher rate reform, income between the basic and higher rate limits. + # 25,140 x 0.45 = 11,313 in the higher band left; 71,860 x 0.24 = 17,246.40. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 28_559.40 + +- name: Residential property additional rate reform, income between the limits. + # The same split applies to each schedule: 25,140 x 0.24 = 6,033.60 and + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.residential_property.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_residential_property: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Carried interest additional rate reform, income between the limits. + # Carried interest is 32% in both bands from 2025-26: 25,140 x 0.32 = + # 8,044.80 in the higher band left and 71,860 x 0.45 = 32,337 above it. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.carried_interest.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_carried_interest: 100_000 + output: + capital_gains_tax: 40_381.80 + +- name: Current law, income above the higher rate limit. + # The higher and additional rates are both 24%: 47,000 x 0.24. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Current law, income between the basic and higher rate limits. + # 97,000 x 0.24 whatever the split. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 23_280 + +- name: Additional rate reform, 200,000 of earnings and 50,000 of gains. + # The personal allowance is fully withdrawn at this income, so taxable + # income is 200,000, above the higher rate limit: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + employment_income: 200_000 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index 23d5687c94..e15169b740 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -27,7 +27,9 @@ def legacy_capital_gains_tax(sim, year): there is a difference in the new formula. Its income and allowance inputs follow the current model (#2253 moved Gift Aid out of the allowances), so it checks how gains are allocated to the schedules, not equivalence with - any earlier treatment of Gift Aid. + any earlier treatment of Gift Aid. Its higher band likewise follows the + current model, which stacks gains on top of taxable income, rather than + giving every gainer the whole band as the formula before #1858 did. """ hmrc = sim.tax_benefit_system.parameters(str(year)).gov.hmrc cgt = hmrc.cgt @@ -61,9 +63,11 @@ def get(name): gains_less_aea - remaining_basic_rate_band, 0 ) higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension + # Gains stack on top of taxable income (TCGA 1992 s. 1H), so the higher + # band left for them starts at taxable income or the basic rate limit. higher_rate_applicable_cg = min_( higher_and_add_rate_applicable_cg, - higher_rate_limit - basic_rate_limit, + max_(higher_rate_limit - max_(taxable_income, basic_rate_limit), 0), ) add_rate_applicable_cg = max_( higher_and_add_rate_applicable_cg - higher_rate_applicable_cg, 0 @@ -317,9 +321,11 @@ def test_main_rate_reform_leaves_the_other_schedules_at_current_law(self): baseline = {s: self.tax(s) for s in self.SCHEDULES} reformed = {s: self.tax(s, self.MAIN_RATES_ONLY) for s in self.SCHEDULES} - # 97,000 above the AEA, all above the basic rate band: 87,440 at 40% - # and 9,560 at 45%. - assert reformed[None] == pytest.approx(39_278) + # Taxable income is 100,000 - 12,570 = 87,430, so the 97,000 above the + # AEA is all above the basic rate band. The higher band left on top of + # that income is 125,140 - 87,430 = 37,710: 37,710 at 40% and 59,290 + # at 45%. + assert reformed[None] == pytest.approx(41_764.5) for schedule in SCHEDULE_INPUTS: assert reformed[schedule] == baseline[schedule], schedule @@ -327,7 +333,7 @@ def test_setting_every_schedule_taxes_every_gain_alike(self): reformed = {s: self.tax(s, self.EVERY_SCHEDULE) for s in self.SCHEDULES} for schedule in self.SCHEDULES: - assert reformed[schedule] == pytest.approx(39_278), schedule + assert reformed[schedule] == pytest.approx(41_764.5), schedule class TestScheduleParameters: diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 27d62c9d4c..545eba8fc7 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -341,7 +341,17 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): 3307.25, 31407.05078125, ], - "capital_gains_tax": [7663.7998046875, 8880, 8880, 8880, 8263.7998046875, 8880] + # The last row's 37,000 of gains is split between the higher band + # left on top of its 88,930 taxable income and the band above it, at + # equal rates; float32 rounds the two parts' sum a thousandth low. + "capital_gains_tax": [ + 7663.7998046875, + 8880, + 8880, + 8880, + 8263.7998046875, + 8879.9990234375, + ] * 2, } for variable, values in expected.items(): diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index 31a414c0fb..bf8ecc4ce4 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -68,7 +68,13 @@ def formula(person, period, parameters): basic_rate_limit = it.rates.uk.thresholds[1] + basic_rate_band_extension remaining_basic_rate_band = max_(basic_rate_limit - taxable_income, 0) higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension - higher_rate_band = higher_rate_limit - basic_rate_limit + # Gains are the top slice of income (TCGA 1992 s. 1H), so the gains + # above the basic rate band start at the basic rate limit or at + # taxable income, whichever is higher. Only the rest of the band up to + # the higher rate limit is left for them. + remaining_higher_rate_band = max_( + higher_rate_limit - max_(taxable_income, basic_rate_limit), 0 + ) # The schedule inputs describe gains before any realisation response. # Relief gains move by their own response, which has its own @@ -161,15 +167,16 @@ def component(amount): after_aea[name], band_left ) - # Gains above the basic rate band are split at the higher rate limit - # so reforms can charge an additional rate; in law the two rates are - # equal. Relief gains are the lowest part of the gains here too. + # Gains above the basic rate band are split at the higher rate limit, + # stacked on top of taxable income, so reforms can charge an + # additional rate; in law the two rates are equal. Relief gains are + # the lowest part of the gains here too. higher_order = ["badr"] + sorted( rates, key=lambda name: -(rates[name].additional_rate - rates[name].higher_rate), ) inside_higher, above_higher = {}, {} - band_left = higher_rate_band + band_left = remaining_higher_rate_band for name in higher_order: inside_higher[name], above_higher[name], band_left = _take( above_basic[name], band_left From eeaf911ccce8c5ce914676f79d2f634c44cfdaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:17:51 +0100 Subject: [PATCH 030/141] Index the savings starting-rate limit after its freeze --- changelog.d/2431.fixed.md | 1 + .../gov/hmrc/create_threshold_indexation.py | 37 ++- .../rates/savings_starter_rate/README.md | 9 + .../rates/savings_starter_rate/allowance.yaml | 33 ++- .../test_savings_starting_rate_indexation.py | 246 ++++++++++++++++++ .../tests/test_threshold_indexation.py | 5 +- 6 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2431.fixed.md create mode 100644 policyengine_uk/tests/test_savings_starting_rate_indexation.py diff --git a/changelog.d/2431.fixed.md b/changelog.d/2431.fixed.md new file mode 100644 index 0000000000..8631399fe9 --- /dev/null +++ b/changelog.d/2431.fixed.md @@ -0,0 +1 @@ +- Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze. diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index e6be77d34e..275812cdd4 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -1,5 +1,5 @@ -"""Index the personal allowance, the basic rate limit and the equivalent NICs -thresholds by September CPI once their freeze ends. +"""Index the personal allowance, basic and savings starting-rate limits, and +the equivalent NICs thresholds by September CPI once their freeze ends. Income tax ---------- @@ -12,6 +12,13 @@ the same percentage and round the result up to a multiple of £100. Each year starts from the rounded amount of the year before. +s21(3A) similarly raises the savings starting-rate limit, but rounds the +full result up to a multiple of £10. Finance Act 2026 s9 fixes that annual +limit at £5,000 through 2030-31 and disapplies s21 for those years. From +2031-32, ``index_savings_starting_rate_limit`` applies the statutory step +to the previous rounded year's limit. Future September CPI is a model +forecast, not a known statutory future amount. + Finance Act 2021 s5, as amended by Finance Act 2023 s5 and Finance Act 2026 s10, sets the basic rate limit at £37,700 and the personal allowance at £12,570 for 2022-23 to 2030-31 and disapplies ss21 and 57 for those years. @@ -85,7 +92,7 @@ (a reform dict, ``parameter_changes``, ``Reform.from_dict``) writes its changes onto the parameters before they are processed, as does a simulation modifier that resets and reprocesses them (as ``no_economic_assumptions`` does), so the -values are rebuilt with the change: a changed CPI forecast moves all seven, +values are rebuilt with the change: a changed CPI forecast moves all eight, and a change to the personal allowance or basic rate limit after 2030-31 also moves the aligned NICs thresholds (see ``policyengine_uk.utils.dated_reforms``). The exceptions edit the processed parameters, so they do not move the others: @@ -105,6 +112,8 @@ # ITA 2007 s57(3) Step 2 and s21(3) Step 2. PERSONAL_ALLOWANCE_MULTIPLE = Decimal(10) BASIC_RATE_LIMIT_MULTIPLE = Decimal(100) +# ITA 2007 s21(3A) Step 2. +SAVINGS_STARTING_RATE_LIMIT_MULTIPLE = Decimal(10) PENNY = Decimal("0.01") # Fiscal-year conversion reads each tax year's value at 30 April. FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30" @@ -124,6 +133,7 @@ INDEXED_THRESHOLDS = ( "gov.hmrc.income_tax.allowances.personal_allowance.amount", "gov.hmrc.income_tax.rates.uk[1].threshold", + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", @@ -162,6 +172,14 @@ def index_basic_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: return round_up_to_multiple(step_1, BASIC_RATE_LIMIT_MULTIPLE) +def index_savings_starting_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: + """ITA 2007 s21(1) and (3A): next year's annual savings starting limit.""" + if increase <= 0: + return previous + amount = previous * (1 + increase) + return round_up_to_multiple(amount, SAVINGS_STARTING_RATE_LIMIT_MULTIPLE) + + def weekly_equivalent(annual: Decimal) -> Decimal: """Annual amount over 52, to the penny, as the parameter files store it.""" return (annual / WEEKS_IN_YEAR).quantize(PENNY, ROUND_HALF_UP) @@ -311,9 +329,13 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_4 = parameters.gov.hmrc.national_insurance.class_4.thresholds personal_allowance_parameter = income_tax.allowances.personal_allowance.amount basic_rate_limit_parameter = income_tax.rates.uk.brackets[1].threshold + savings_starting_rate_limit_parameter = ( + income_tax.rates.savings_starter_rate.allowance + ) indexed = [ personal_allowance_parameter, basic_rate_limit_parameter, + savings_starting_rate_limit_parameter, class_1.primary_threshold, class_1.upper_earnings_limit, class_1.secondary_threshold, @@ -333,6 +355,12 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: basic_rate_limit = tax_year_path( basic_rate_limit_parameter, years, increases, index_basic_rate_limit ) + savings_starting_rate_limit = tax_year_path( + savings_starting_rate_limit_parameter, + years, + increases, + index_savings_starting_rate_limit, + ) higher_rate_threshold = { year: personal_allowance[year] + basic_rate_limit[year] for year in years } @@ -342,6 +370,9 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: write_unstated_tax_years(personal_allowance_parameter, personal_allowance) write_unstated_tax_years(basic_rate_limit_parameter, basic_rate_limit) + write_unstated_tax_years( + savings_starting_rate_limit_parameter, savings_starting_rate_limit + ) write_unstated_tax_years(class_4.lower_profits_limit, personal_allowance) write_unstated_tax_years(class_4.upper_profits_limit, higher_rate_threshold) write_unstated_tax_years( diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md index 6ab9344984..bc532db98a 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md @@ -1 +1,10 @@ # Savings starter rate + +The annual starting-rate limit remains £5,000 through 2030–31 under Finance +Act 2026 section 9. From 2031–32, Income Tax Act 2007 section 21(3A) increases +the preceding rounded limit by the positive September CPI rise and rounds the +full amount upward to a multiple of £10. Zero or negative CPI leaves the +limit unchanged. `create_threshold_indexation.py` generates these annual +values through the economic-assumptions horizon; future CPI inputs are +forecasts or scenario assumptions. The pre-2015 parameter history is retained +without being verified by this prospective change. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml index bb57a28247..3cc8f71baa 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml @@ -1,10 +1,33 @@ -description: Starter allowance for savings. +description: Annual starting-rate limit for savings income. +# ITA 2007 s21(3A) increases the previous rounded annual limit by positive +# September CPI and rounds the result up to a multiple of £10. FA 2026 s9 +# fixes £5,000 through 2030-31 and disapplies that indexation. From 2031-32, +# create_threshold_indexation.py applies the statutory step, so this file +# has no generic uprating. The pre-2015 model entry is retained unchanged; +# this prospective indexation fix does not verify that historical amount. values: 2010-04-01: 5_000 + 2015-04-06: + value: 5_000 + metadata: + reference: + - title: Finance Act 2014 s. 3 (2) and (3) + href: https://www.legislation.gov.uk/ukpga/2014/26/section/3 + 2026-04-06: 5_000 + 2027-04-06: 5_000 + 2028-04-06: 5_000 + 2029-04-06: 5_000 + 2030-04-06: 5_000 metadata: - period: week + period: year unit: currency-GBP label: Savings starter rate allowance - reference: - - title: Tax on savings interest - href: https://www.gov.uk/apply-tax-free-interest-on-savings + reference: + - title: Income Tax Act 2007 s. 12 (3) and (4) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/12 + - title: Income Tax Act 2007 s. 21 (1), (3A), (5) and (6) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/21 + - title: Finance Act 2026 s. 9 (freeze for 2026-27 to 2030-31) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/9 + - title: Tax on savings interest + href: https://www.gov.uk/apply-tax-free-interest-on-savings diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py new file mode 100644 index 0000000000..f5df01c766 --- /dev/null +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -0,0 +1,246 @@ +"""ITA 2007 s21(3A): annual savings limit, September CPI and £10 ceilings. + +The £5,000 freeze ends in 2030-31 (FA 2026 s9). Future CPI in these +tests is an explicit scenario assumption, not a statutory future amount. +Savings-only income avoids the separately tracked starter-band taper #1799. +""" + +from decimal import Decimal +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.reforms import Reform + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.parameters.gov.hmrc import create_threshold_indexation +from policyengine_uk.utils.scenario import Scenario + +LIMIT = "gov.hmrc.income_tax.rates.savings_starter_rate.allowance" +CPI = "gov.economic_assumptions.statutory_uprating_inputs.cpi_september" +PA = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +PARAMETER_FILE = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml" +) + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +def limit(system, year, baseline=False): + root = system.parameters.baseline if baseline else system.parameters + return root.get_child(LIMIT)(f"{year}-01-01") + + +def with_cpi(system, rates): + result = system.clone() + result.reset_parameters() + parameter = result.parameters.get_child(CPI) + for year, rate in rates.items(): + parameter.update(period=f"year:{year}-09-01:1", value=rate) + result.process_parameters() + return result + + +@pytest.mark.parametrize("year", range(2026, 2031)) +def test_starting_rate_limit_is_frozen_through_2030(system, year): + assert limit(system, year) == 5_000 + + +def test_starting_rate_limit_has_annual_metadata_without_double_uprating(): + metadata = yaml.safe_load(PARAMETER_FILE.read_text())["metadata"] + assert metadata["period"] == "year" + assert "uprating" not in metadata + + +@pytest.mark.parametrize( + "previous,increase,expected", + [ + (5_000, "0.02", 5_100), + (5_100, "0.02", 5_210), + (5_210, "0.02", 5_320), + (5_000, "0.001", 5_010), + (5_000, "0.002", 5_010), + (5_000, "0", 5_000), + (5_100, "-0.01", 5_100), + ], +) +def test_starting_rate_statutory_rounding(previous, increase, expected): + assert create_threshold_indexation.index_savings_starting_rate_limit( + Decimal(previous), Decimal(increase) + ) == Decimal(expected) + + +def test_starting_rate_uses_the_previous_rounded_annual_limit(system): + indexed = with_cpi(system, dict.fromkeys(range(2030, 2039), 0.02)) + for year, expected in [(2030, 5_000), (2031, 5_100), (2032, 5_210), (2033, 5_320)]: + assert limit(indexed, year) == expected + if year > 2030: + assert limit(indexed, year, baseline=True) == expected + assert limit(indexed, 2040) == limit(indexed, 2039) + # Reapplying the helper does not append another year's increase. + create_threshold_indexation.add_threshold_indexation(indexed.parameters) + assert limit(indexed, 2031) == 5_100 + + +@pytest.mark.parametrize("rate", [0, -0.01]) +def test_nonpositive_cpi_preserves_the_preceding_limit(system, rate): + indexed = with_cpi(system, {2030: 0.02, 2031: rate}) + assert limit(indexed, 2031) == 5_100 + assert limit(indexed, 2032) == 5_100 + + +def savings_situation(): + years = range(2030, 2034) + return { + "people": { + "saver": { + "age": {year: 40 for year in years}, + "savings_interest_income": {year: 20_000 for year in years}, + } + }, + "benunits": {"benunit": {"members": ["saver"]}}, + "households": { + "household": { + "members": ["saver"], + "region": {year: "LONDON" for year in years}, + } + }, + } + + +@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"]) +def test_indexed_starting_rate_reaches_final_savings_income_tax(route): + # Hold the PA at £12,570 to isolate this band's effect. With £20,000 + # interest and the unchanged £1,000 PSA, £6,430 remains before the + # starting rate. The unchanged post-2027 basic savings rate is 22%. + changes = { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + } + if route == "parameter_changes": + simulation = Simulation( + situation=savings_situation(), + scenario=Scenario(parameter_changes=changes), + ) + else: + reform = changes if route == "dict" else Reform.from_dict(changes) + simulation = Simulation(situation=savings_situation(), reform=reform) + for year, starter, tax in [ + (2030, 5_000, 314.60), + (2031, 5_100, 292.60), + (2032, 5_210, 268.40), + (2033, 5_320, 244.20), + ]: + assert simulation.calculate("personal_allowance", year)[0] == 12_570 + assert simulation.calculate("savings_allowance", year)[0] == 1_000 + assert simulation.calculate("savings_starter_rate_income", year)[0] == starter + np.testing.assert_allclose( + simulation.calculate("savings_income_tax", year), [tax], rtol=0, atol=0.001 + ) + np.testing.assert_allclose( + simulation.calculate("income_tax", year), [tax], rtol=0, atol=0.001 + ) + + +def savings_simulation(route, changes): + if route == "parameter_changes": + return Simulation( + situation=savings_situation(), + scenario=Scenario(parameter_changes=changes), + ) + reform = changes if route == "dict" else Reform.from_dict(changes) + return Simulation(situation=savings_situation(), reform=reform) + + +@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"]) +def test_dated_limit_override_resumes_indexation_from_the_changed_limit(route): + # ITA 2007 s21(3A) uses the previous year's limit, including a reform's + # £6,000, then rounds the 2% increase up to £10: £6,120 in 2032. + # The separately constructed CPI/PA-only control retains its own path. + changes = { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + } + control = savings_simulation(route, changes) + simulation = savings_simulation( + route, {**changes, LIMIT: {"2031-04-06.2032-04-05": 6_000}} + ) + assert limit(simulation.tax_benefit_system, 2030) == 5_000 + assert limit(simulation.tax_benefit_system, 2031) == 6_000 + assert limit(simulation.tax_benefit_system, 2032) == 6_120 + assert limit(control.tax_benefit_system, 2032) == 5_210 + # With genuine £20,000 interest, the held PA and natural PSA leave + # £6,430 before the starting rate: (6,430 - limit) x 22%. + for current, year, tax in [ + (simulation, 2031, 94.60), + (simulation, 2032, 68.20), + (control, 2032, 268.40), + ]: + assert current.calculate("personal_allowance", year)[0] == 12_570 + assert current.calculate("savings_allowance", year)[0] == 1_000 + for variable in ("savings_income_tax", "income_tax"): + np.testing.assert_allclose( + current.calculate(variable, year), [tax], rtol=0, atol=0.001 + ) + + +@pytest.mark.parametrize( + "start,stop,expected", + [ + ("2029-04-06", "2030-04-29", (6_000, 5_000, 5_100, 5_210)), + ("2029-04-06", "2030-04-30", (6_000, 6_000, 6_120, 6_250)), + ("2030-04-06", "2030-04-29", (5_000, 5_000, 5_100, 5_210)), + ], +) +def test_override_replacing_the_last_file_value_keeps_uncovered_years( + start, stop, expected +): + # The file's last anchor is 2030-04-06. Replacing it must retain the + # stated 2030 freeze when the change ends before the 30 April sample. + # A change covering that sample instead supplies the previous-year + # base for 2031; £6,120 x 1.02 then ceilings to £6,250 for 2032. + simulation = savings_simulation( + "dict", + { + CPI: {"2030-09-01.2038-08-31": 0.02}, + LIMIT: {f"{start}.{stop}": 6_000}, + }, + ) + assert [ + limit(simulation.tax_benefit_system, year) for year in range(2029, 2033) + ] == list(expected) + + +def test_reset_and_reprocessing_keep_the_changed_savings_indexation_path(): + simulation = savings_simulation( + "dict", + { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + LIMIT: {"2031-04-06.2032-04-05": 6_000}, + }, + ) + system = simulation.tax_benefit_system + assert limit(system, 2032) == 6_120 + system.reset_parameters() + system.reset_parameters() + system.process_parameters() + assert [limit(system, year) for year in range(2030, 2033)] == [ + 5_000, + 6_000, + 6_120, + ] + assert system._parameters_before_reset is None + # Calculate after rebuilding, so this is not a cached pre-reset award. + assert simulation.calculate("personal_allowance", 2032)[0] == 12_570 + assert simulation.calculate("savings_allowance", 2032)[0] == 1_000 + for variable in ("savings_income_tax", "income_tax"): + np.testing.assert_allclose( + simulation.calculate(variable, 2032), [68.20], rtol=0, atol=0.001 + ) diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 23211c9960..0e8961f917 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -408,6 +408,9 @@ def series(base, first, last, month_day="04-06"): } }, "rates": { + "savings_starter_rate": { + "allowance": series(5_000, 2021, 2030) + }, "uk": { "brackets": [ { @@ -424,7 +427,7 @@ def series(base, first, last, month_day="04-06"): ), }, ] - } + }, }, }, "national_insurance": { From f9f43d5bfa5930ee23926ec416918fb3c8446b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:36:39 +0100 Subject: [PATCH 031/141] Apply Carer Support Payment earnings limit --- changelog.d/2345.fixed.md | 1 + .../carer-support-payment-earnings.md | 62 +++ .../carer_support_payment/earnings_limit.yaml | 30 ++ .../earnings_rounding_precision.yaml | 11 + .../carer_support_payment_earnings_limit.yaml | 390 ++++++++++++++++++ .../tests/test_carer_support_payment.py | 143 +++++++ .../carer_support_payment_pre_overlap.py | 27 +- 7 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2345.fixed.md create mode 100644 docs/book/assumptions/carer-support-payment-earnings.md create mode 100644 policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml create mode 100644 policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml diff --git a/changelog.d/2345.fixed.md b/changelog.d/2345.fixed.md new file mode 100644 index 0000000000..2644dc96ca --- /dev/null +++ b/changelog.d/2345.fixed.md @@ -0,0 +1 @@ +- Applied the Carer Support Payment earnings limit, including its own dated thresholds and favorable fractional-penny rule. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md new file mode 100644 index 0000000000..c2c87df5b1 --- /dev/null +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -0,0 +1,62 @@ +# Carer Support Payment earnings + +Carer Support Payment (CSP) has an earnings limit. Regulation 14 of the +[Carer's Assistance Regulations 2023](https://www.legislation.gov.uk/ssi/2023/302/regulation/14) +excludes a carer when the earnings counted for the relevant award week exceed +the limit. A reported Carer's Allowance award does not exempt them. + +## Limits and penny fractions + +CSP has its own Sunday effective dates, separate from Carer's Allowance: + +| Effective date | Weekly limit | +| --- | ---: | +| 19 November 2023 | £139 | +| 7 April 2024 | £151 | +| 6 April 2025 | £196 | +| 5 April 2026 | £204 | + +The limit is inclusive. [Schedule 2 paragraph 4](https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4) +handles a fractional penny in the individual's favour. At the final earnings +test, £204.005 a week counts as £204.00 and passes; £204.01 exceeds the £204 +limit. The model compares annual earnings with the first excluded weekly +penny amount multiplied by 52, in the earnings array's numeric precision. +This applies the penny rule without an extra tolerance or rounding annual +earnings before converting them to a weekly amount. + +## Annual earnings approximation + +The model reuses `carers_allowance_earnings`, a Person-level annual amount in +pounds. It deducts modelled employment tax and primary Class 1 contributions, +notional tax and contributions on self-employment profits, and half the +individual's pension contributions. Partner and cared-for-person earnings do +not enter the carer's test. Employer pension contributions are not deducted. + +Annual net earnings are spread evenly over 52 weeks. Constant-through-year +inputs match the weekly comparison; varying earnings may not. Originally, +regulation 14 assessed that award week's earnings. From 16 November 2025 it +assesses the immediately preceding award week. Annual inputs cannot distinguish +those weeks or reproduce all pay-period and averaging cases in schedule 2. + +The shared reader approximates employment tax using PAYE on pay alone with a +standard code, rather than actual withholding. It does not represent all +statutory care-charge deductions and disregards. Reusing it does not certify +that every schedule 2 calculation is implemented. + +## Coverage and other conditions + +Raw parameter dates retain the statutory dates above. The existing annual +parameter conversion samples 30 April for a named fiscal year; the model's +2026 value therefore represents the £204 limit, rather than a claim that it +legally began on 1 January. CSP remains modelled from 2025, with the previous +Carer's Allowance route retained for Scotland in earlier model years. This +change does not extend coverage back to the 2023 pilot. + +The existing caring-hours, reported-receipt and take-up conditions remain. +Age, education, the cared-for person's qualifying-benefit conditions and the +full claim-status rules are separate gaps. Earnings above the limit remove +underlying CSP entitlement before the overlapping-benefit calculation. This +differs from an overlapping benefit reducing an otherwise entitled person's +payment to zero. The Scottish Carer Supplement is paid only when CSP is +payable. Caring hours and reported caring can still qualify a person for the +Universal Credit carer element, which does not apply this earnings limit. diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml new file mode 100644 index 0000000000..7feed18873 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml @@ -0,0 +1,30 @@ +description: Weekly net earnings limit for Carer Support Payment, after the favorable fractional-penny rule. +metadata: + economy: false + period: week + unit: currency-GBP + label: Carer Support Payment earnings limit + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulation 14 + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14 +values: + 2023-11-19: + value: 139 + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulations 1 and 14 as made + href: https://www.legislation.gov.uk/ssi/2023/302/made + 2024-04-07: + value: 151 + reference: + - title: Regulation 14 as amended from 7 April 2024 by SSI 2024/105, regulation 7(2) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14/2024-04-07 + 2025-04-06: + value: 196 + reference: + - title: Regulation 14 as amended from 6 April 2025 by SSI 2025/100, regulation 13(2) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14/2025-04-06 + 2026-04-05: + value: 204 + reference: + - title: The Social Security (Up-rating) (Miscellaneous Amendments) (Scotland) Regulations 2026, regulation 12(2) + href: https://www.legislation.gov.uk/ssi/2026/170/regulation/12/made diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml new file mode 100644 index 0000000000..9b95aa9e31 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml @@ -0,0 +1,11 @@ +description: Penny precision for the favorable fractional-penny calculation of Carer Support Payment weekly earnings. +values: + 2023-11-19: 0.01 +metadata: + economy: false + period: week + unit: currency-GBP + label: Carer Support Payment earnings rounding precision + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, schedule 2 paragraph 4 + href: https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4 diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml new file mode 100644 index 0000000000..8444e2fae7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml @@ -0,0 +1,390 @@ +# CSP earnings condition: SSI2023/302reg14 andSch2para4. +# Constant-through-year actual pay; no earnings/eligibility/award overrides. +- name: Scottish carer with forty thousand pounds of pay gets no CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 40000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + carer_support_payment_pre_overlap: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 + is_carer_for_benefits: + - true + - false +- name: Exactly two hundred and four pounds a week passes the earnings condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 + carers_allowance_earnings: + - 10608 + - 0 + scottish_carer_supplement: + - 608.4 + - 0 +- name: A half penny weekly residual is disregarded in the carers favour + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608.26 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 +- name: A whole penny over the weekly limit excludes CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608.52 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Half of the individuals pension contribution brings earnings below the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 11000 + care_hours: 40 + employee_pension_contributions: 1000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 + carers_allowance_earnings: + - 10500 + - 0 +- name: The same pay without the pension deduction exceeds the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 11000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Reported Carers Allowance does not bypass the CSP earnings bar + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 40000 + care_hours: 0 + carers_allowance_reported: 4000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + is_carer_for_benefits: + - true + - false +- name: A carer who would not claim receives no CSP even below the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 8000 + care_hours: 40 + would_claim_carers_allowance: false + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: The two hundred and four pound limit is not applied to fiscal year twenty twenty five + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10192.52 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Exactly the prior years one hundred and ninety six pound limit passes + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10192 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4331.6 + - 0 +- name: England keeps Carers Allowance instead of CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 5000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: ENGLAND + output: + carer_support_payment: + - 0 + - 0 + carers_allowance: + - 4495.4 + - 0 +- name: Below limit earnings do not replace the existing care hours condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 5000 + care_hours: 10 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: The earnings bar removes underlying CSP before any pension overlap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + employment_income: 40000 + care_hours: 40 + state_pension: 2000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + carer_support_payment_pre_overlap: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 + is_entitled_to_carer_benefit: + - false + - false diff --git a/policyengine_uk/tests/test_carer_support_payment.py b/policyengine_uk/tests/test_carer_support_payment.py index c833bfae38..f75a148796 100644 --- a/policyengine_uk/tests/test_carer_support_payment.py +++ b/policyengine_uk/tests/test_carer_support_payment.py @@ -140,3 +140,146 @@ def test_csp_counts_for_uc_non_dep_exemption_and_housing_benefit_income(): assert sim.calculate("uc_non_dep_deduction_exempt", YEAR_2025)[0] assert hb_income == max(0, csp_amount - hb_disregard) + + +def test_csp_earnings_limit_is_independent_of_carers_allowance_limit(): + year = 2026 + inputs = _situation(year, employment_income={year: 11_000}) + baseline = Simulation(situation=inputs) + csp_reform = Simulation( + situation=inputs, + reform={ + "gov.social_security_scotland.carer_support_payment.earnings_limit": 1_000 + }, + ) + ca_reform = Simulation( + situation=inputs, + reform={"gov.dwp.carers_allowance.earnings_limit": 1_000}, + ) + assert baseline.calculate("carer_support_payment", year)[0] == 0 + assert csp_reform.calculate("carer_support_payment", year)[0] == pytest.approx( + 4_495.40, abs=0.01 + ) + assert ca_reform.calculate("carer_support_payment", year)[0] == 0 + + # A CSP parameter reform cannot change the existing England CA route. + inputs["households"]["household"]["country"] = {year: "ENGLAND"} + england = Simulation( + situation=inputs, + reform={ + "gov.social_security_scotland.carer_support_payment.earnings_limit": 1_000 + }, + ) + assert england.calculate("carers_allowance", year)[0] == 0 + + +def test_csp_statutory_dates_and_annual_model_coverage(): + from pathlib import Path + + from policyengine_core.parameters.helpers import load_parameter_file + from policyengine_uk import CountryTaxBenefitSystem + + parameter_file = ( + Path(__file__).resolve().parents[1] + / "parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml" + ) + raw = load_parameter_file(str(parameter_file), name="earnings_limit") + for date, expected in [ + ("2023-11-19", 139), + ("2024-04-06", 139), + ("2024-04-07", 151), + ("2025-04-05", 151), + ("2025-04-06", 196), + ("2026-04-04", 196), + ("2026-04-05", 204), + ]: + assert raw(date) == expected + system = CountryTaxBenefitSystem() + for year, expected in [(2024, 151), (2025, 196), (2026, 204)]: + assert ( + system.parameters( + str(year) + ).gov.social_security_scotland.carer_support_payment.earnings_limit + == expected + ) + # The statutory pilot start does not expand the existing model coverage. + for year in [2023, 2024]: + sim = Simulation(situation=_situation(year)) + assert sim.calculate("carer_support_payment", year)[0] == 0 + + +def test_csp_first_excluded_penny_in_the_actual_holder_dtype(record_property): + import json + + import numpy as np + + year = 2026 + critical = Simulation( + situation=_situation(year, employment_income={year: 10_608.52}) + ) + earnings = critical.calculate("carers_allowance_earnings", year) + encoded_boundary = np.asarray(10_608.52, dtype=earnings.dtype) + assert critical.calculate("carer_support_payment", year)[0] == 0 + observations = [] + # These neighbours check the model's encoded legal penny boundary, rather + # than claim that arbitrary binary fractions are a separate statutory rule. + for pay, expected in [ + (np.nextafter(encoded_boundary, -np.inf, dtype=earnings.dtype), 4_495.40), + (encoded_boundary, 0), + (np.nextafter(encoded_boundary, np.inf, dtype=earnings.dtype), 0), + ]: + sim = Simulation( + situation=_situation(year, employment_income={year: float(pay)}) + ) + counted = sim.calculate("carers_allowance_earnings", year) + actual = sim.calculate("carer_support_payment", year)[0] + assert actual == pytest.approx(expected, abs=0.01) + observations.append( + { + "pay": float(pay), + "counted_annual": float(counted[0]), + "dtype": str(counted.dtype), + "CSP": float(actual), + "expected": expected, + } + ) + record_property("CSP_holder_boundary", json.dumps(observations, sort_keys=True)) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_csp_earnings_are_personal_and_request_order_independent(reverse): + year = 2026 + people, benunits, households = {}, {}, {} + for family, pay in [("low", 8_000), ("high", 40_000)]: + names = [family + suffix for suffix in ["_carer", "_partner", "_caree"]] + people[names[0]] = { + "age": {year: 45}, + "care_hours": {year: 40}, + "employment_income": {year: pay}, + "is_parent": {year: True}, + } + people[names[1]] = { + "age": {year: 43}, + "employment_income": {year: 40_000 if family == "low" else 0}, + "is_parent": {year: True}, + } + people[names[2]] = {"age": {year: 18}, "pip_dl_category": {year: "STANDARD"}} + benunits[family] = {"members": list(reversed(names)) if reverse else names} + households[family] = { + "members": list(reversed(names)) if reverse else names, + "country": {year: "SCOTLAND"}, + } + if reverse: + people = dict(reversed(list(people.items()))) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + if reverse: + sim.calculate("is_entitled_to_carer_benefit", year) + values = sim.calculate("carer_support_payment", year) + by_name = dict(zip(sim.populations["person"].ids, values)) + assert by_name["low_carer"] == pytest.approx(4_495.40, abs=0.01) + assert by_name["high_carer"] == 0 + assert all( + value == 0 for name, value in by_name.items() if not name.endswith("_carer") + ) diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py index 8826fc1a5b..a2306a9965 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py @@ -10,7 +10,12 @@ class carer_support_payment_pre_overlap(Variable): "to before regulation 16(2) of the Carer Support Payment Regulations " "reduces it by an overlapping benefit. A carer whose payment is " "reduced to £0 keeps this underlying entitlement. " - "carer_support_payment is the amount given." + "carer_support_payment is the amount given. The earnings condition uses " + "the existing annual Carer's Allowance net-earnings reader, spread " + "evenly over 52 weeks. This approximates constant earnings; it cannot " + "resolve the award-week or, from 16 November 2025, preceding-award-week " + "assessment for varying earnings. The reader's standard-code PAYE and " + "unrepresented care-charge/disregard assumptions also apply." ) definition_period = YEAR unit = GBP @@ -18,17 +23,35 @@ class carer_support_payment_pre_overlap(Variable): "https://www.legislation.gov.uk/asp/2018/9/part/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/3", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/14", + "https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4", ) def formula(person, period, parameters): in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" csp_in_effect = period.start.year >= 2025 + if not csp_in_effect: + return person.empty_array() csp = parameters(period).gov.social_security_scotland.carer_support_payment weekly_care_hours = person("care_hours", period) meets_hours = weekly_care_hours >= csp.min_hours receives_ca = person("carers_allowance_reported", period) > 0 would_claim = person("would_claim_carers_allowance", period) + earnings = person("carers_allowance_earnings", period) + # Schedule 2 paragraph 4 disregards an adverse fraction of a penny. + # The first excluded weekly amount is the limit plus one penny. + # Compare annual amounts in the reader's dtype so the encoded exact + # penny boundary is not admitted through scalar/array rounding. + first_excluded_annual_earnings = np.asarray( + (csp.earnings_limit + csp.earnings_rounding_precision) * WEEKS_IN_YEAR, + dtype=earnings.dtype, + ) + meets_earnings = earnings < first_excluded_annual_earnings eligible = ( - in_scotland & csp_in_effect & (meets_hours | receives_ca) & would_claim + in_scotland + & csp_in_effect + & (meets_hours | receives_ca) + & meets_earnings + & would_claim ) return eligible * csp.rate * WEEKS_IN_YEAR From 312b572eff3d70ccdbab8189ce1624eaa862ea35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:46:38 +0100 Subject: [PATCH 032/141] Require minimum ages for carer benefits and DLA mobility --- changelog.d/1793.fixed.md | 1 + docs/book/assumptions/benefit-minimum-ages.md | 66 +++ .../gov/dwp/carers_allowance/min_age.yaml | 14 + .../gov/dwp/dla/mobility/higher_min_age.yaml | 12 + .../gov/dwp/dla/mobility/lower_min_age.yaml | 12 + .../carer_support_payment/min_age.yaml | 10 + .../carer_and_dla_minimum_ages.yaml | 484 ++++++++++++++++++ .../tests/test_carer_and_dla_minimum_ages.py | 224 ++++++++ .../gov/dwp/carers_allowance_pre_overlap.py | 2 + .../variables/gov/dwp/dla/dla_m.py | 9 +- .../carer_support_payment_pre_overlap.py | 8 +- 11 files changed, 839 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1793.fixed.md create mode 100644 docs/book/assumptions/benefit-minimum-ages.md create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml create mode 100644 policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml create mode 100644 policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml create mode 100644 policyengine_uk/tests/test_carer_and_dla_minimum_ages.py diff --git a/changelog.d/1793.fixed.md b/changelog.d/1793.fixed.md new file mode 100644 index 0000000000..167067ae56 --- /dev/null +++ b/changelog.d/1793.fixed.md @@ -0,0 +1 @@ +Applied the minimum ages to computed Carer's Allowance, Carer Support Payment and DLA mobility awards, preserving DLA care and explicit amount overrides. diff --git a/docs/book/assumptions/benefit-minimum-ages.md b/docs/book/assumptions/benefit-minimum-ages.md new file mode 100644 index 0000000000..feab20df62 --- /dev/null +++ b/docs/book/assumptions/benefit-minimum-ages.md @@ -0,0 +1,66 @@ +# Benefit minimum ages + +Computed Carer's Allowance, Carer Support Payment (CSP) and Disability Living +Allowance (DLA) mobility awards apply these minimum ages to each person's existing +annual `age` input: + +| Computed benefit | Minimum age | +| --- | ---: | +| Carer's Allowance | 16 | +| CSP | 16 | +| Higher DLA mobility component | 3 | +| Lower DLA mobility component | 5 | + +The Carer's Allowance condition appears in section 70(3) of the +[Great Britain Act](https://www.legislation.gov.uk/ukpga/1992/4/section/70) and its +[Northern Ireland counterpart](https://www.legislation.gov.uk/ukpga/1992/7/section/70). +CSP's condition is in +[regulation 4(1)](https://www.legislation.gov.uk/ssi/2023/302/regulation/4). +The DLA mobility ages and corresponding higher/lower routes appear in sections +73(1), (1A) and (11) of the +[Great Britain Act](https://www.legislation.gov.uk/ukpga/1992/4/section/73) and +[Northern Ireland Act](https://www.legislation.gov.uk/ukpga/1992/7/section/73). +Other qualifying conditions remain necessary. These gates do not implement a full +eligibility assessment or change education, caring, disability, duration, upper-age +or programme-replacement rules. + +## Reported inputs and amount overrides + +A positive `carers_allowance_reported` value or sufficient caring hours cannot +bypass the age condition in the computed CA/CSP pre-overlap amount. A DLA mobility +category similarly cannot bypass its category's age condition. The raw reported +amounts and categories remain stored inputs. + +Explicitly supplying a pre-overlap or final amount keeps the model's generic input +override behavior. For example, an explicit `carers_allowance` or `dla_m` amount +can override its formula. Such an override is not evidence of statutory +entitlement. Report-only positive carer cases also preserve an existing proxy; +reported receipt does not legally waive the qualifying-care requirements. + +DLA care has no categorical minimum-age floor. Its separate needs, duration and +child-comparison conditions still apply, as set out in +[section 72](https://www.legislation.gov.uk/ukpga/1992/4/section/72) and its +[Northern Ireland counterpart](https://www.legislation.gov.uk/ukpga/1992/7/section/72). +A care category for an infant assumes those conditions; it is not automatic +entitlement. DLA totals continue to add the care and mobility components. + +## Dates and the annual model + +The CA age parameter begins on 1 January 2015 as the model's existing coverage +anchor. This is not a claim that the legal age condition commenced in 2015. +CSP's raw age parameter starts on 19 November 2023, the commencement of regulation +4. The DLA mobility parameters start on 9 April 2001, as recorded in the Acts' +amendment annotations. These dates do not fill missing historical benefit rates +or expand the existing Scottish CA-to-CSP model switch from 2025. + +`age` is a Person/YEAR floating-point stock, with an existing default of 40 when +omitted. The gates compare it directly, without flooring or rounding fractional +ages. Awards retain the existing annual rate-times-52 convention. They do not +calculate exact birthdays, part-year awards or award weeks. In particular, this +does not newly implement CSP regulation 4(2)'s leap-day birthday rule. + +At the unchanged 2026 rates, an otherwise qualifying carer aged 16 receives +£4,495.40 before overlap; an under-16 computed award is zero. Higher and lower DLA +mobility amounts are respectively £4,160 and £1,575.60 annually once the relevant +age floor is met. These conditional examples do not estimate a population effect +or establish independently encoded parity. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml new file mode 100644 index 0000000000..860c60873a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml @@ -0,0 +1,14 @@ +description: Minimum age for computed Carer's Allowance entitlement. +# Existing model coverage anchor, not the statutory commencement date. +# The age condition predates the model's supported 2015 fiscal year. +values: + 2015-01-01: 16 +metadata: + unit: year + period: year + label: Carer's Allowance minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 70(3) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/70 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 70(3) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/70 diff --git a/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml b/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml new file mode 100644 index 0000000000..4fcf8ad82d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml @@ -0,0 +1,12 @@ +description: Minimum age for the higher DLA mobility component. +values: + 2001-04-09: 3 +metadata: + unit: year + period: year + label: Higher DLA mobility minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 73(1A)(a) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/73 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 73(1A)(a) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/73 diff --git a/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml b/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml new file mode 100644 index 0000000000..ceb4c75c13 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml @@ -0,0 +1,12 @@ +description: Minimum age for the lower DLA mobility component. +values: + 2001-04-09: 5 +metadata: + unit: year + period: year + label: Lower DLA mobility minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 73(1A)(b) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/73 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 73(1A)(b) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/73 diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml new file mode 100644 index 0000000000..de802d9190 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml @@ -0,0 +1,10 @@ +description: Minimum age for computed Carer Support Payment entitlement. +values: + 2023-11-19: 16 +metadata: + unit: year + period: year + label: Carer Support Payment minimum age + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulation 4(1) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/4 diff --git a/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml b/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml new file mode 100644 index 0000000000..b3ad82b939 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml @@ -0,0 +1,484 @@ +# Conditional age-boundary cash cases, with the existing annual age/rate convention. +# Positive carers otherwise meet the qualifying-care/disability/duration conditions, +# have no earnings/overlapping personal benefit, and are not in full-time education. +# Report-only adult cases retain an existing input proxy, not a waiver of care rules. +# DLA care categories assume the separate needs/duration conditions; infant care is +# not automatic. No final amount, pre-overlap, earnings or eligibility is supplied. +- name: Reported Carers Allowance cannot pay a fifteen year old + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Thirty five caring hours cannot pay Carers Allowance below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Reported Carers Allowance retains the existing proxy at sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 4495.4 + carer_support_payment: + - 0 + - 0 +- name: A sixteen year old Northern Ireland carer passes the age floor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: NORTHERN_IRELAND + output: + carers_allowance: + - 0 + - 4495.4 + carer_support_payment: + - 0 + - 0 +- name: A fractional age below sixteen is not rounded up for Carers Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15.5 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: WALES + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Scottish Carers Allowance also screens age in the retained twenty twenty four route + period: 2024 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: An eligible Scottish carer retains Carers Allowance in twenty twenty four + period: 2024 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 4258.8 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: Reported Carers Allowance cannot pay CSP below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: Thirty five caring hours cannot pay CSP below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: CSP and its supplement retain the sixteen year old age boundary + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 4495.4 + scottish_carer_supplement: + - 0 + - 608.4 +- name: Reported receipt retains the CSP proxy at sixteen in twenty twenty five + period: 2025 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 4331.6 + scottish_carer_supplement: + - 0 + - 0 +- name: A fractional age below sixteen is not rounded up for CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15.5 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: DLA mobility uses each child and categorys own minimum age + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caregiver: + age: 40 + higher_under: + age: 2 + dla_m_category: HIGHER + dla_sc_category: HIGHER + higher_at: + age: 3 + dla_m_category: HIGHER + lower_under: + age: 4 + dla_m_category: LOWER + dla_sc_category: HIGHER + lower_at: + age: 5 + dla_m_category: LOWER + benunits: + family: + members: + - caregiver + - higher_under + - higher_at + - lower_under + - lower_at + households: + home: + members: + - caregiver + - higher_under + - higher_at + - lower_under + - lower_at + country: ENGLAND + output: + dla_m: + - 0 + - 0 + - 4160 + - 0 + - 1575.6 + dla_sc: + - 0 + - 5959.2 + - 0 + - 5959.2 + - 0 + dla: + - 0 + - 5959.2 + - 4160 + - 5959.2 + - 1575.6 +- name: DLA care has no categorical age floor and fractional mobility ages are not rounded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caregiver: + age: 40 + infant: + age: 1 + dla_m_category: NONE + dla_sc_category: HIGHER + higher_under: + age: 2.5 + dla_m_category: HIGHER + lower_under: + age: 4.5 + dla_m_category: LOWER + benunits: + family: + members: + - caregiver + - infant + - higher_under + - lower_under + households: + home: + members: + - caregiver + - infant + - higher_under + - lower_under + country: NORTHERN_IRELAND + output: + dla_m: + - 0 + - 0 + - 0 + - 0 + dla_sc: + - 0 + - 5959.2 + - 0 + - 0 + dla: + - 0 + - 5959.2 + - 0 + - 0 diff --git a/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py b/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py new file mode 100644 index 0000000000..9f4166cb42 --- /dev/null +++ b/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py @@ -0,0 +1,224 @@ +"""Minimum-age cash/override contracts using the existing annual FLOAT age. + +Other qualifying-care/disability/duration conditions are assumed. These tests +preserve the category/reported-receipt proxies; they do not audit eligibility. +""" + +from pathlib import Path + +import pytest + +from policyengine_core.parameters.helpers import load_parameter_file +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +YEAR = 2026 + + +def situation(person, country="ENGLAND"): + return { + "people": { + "caregiver": {"age": {YEAR: 50}, "pip_dl_category": {YEAR: "STANDARD"}}, + "person": {name: {YEAR: value} for name, value in person.items()}, + }, + "benunits": {"family": {"members": ["caregiver", "person"]}}, + "households": { + "home": {"members": ["caregiver", "person"], "country": {YEAR: country}} + }, + } + + +@pytest.mark.parametrize( + "path,first,minimum", + [ + ("gov.dwp.carers_allowance.min_age", "2015-01-01", 16), + ( + "gov.social_security_scotland.carer_support_payment.min_age", + "2023-11-19", + 16, + ), + ("gov.dwp.dla.mobility.higher_min_age", "2001-04-09", 3), + ("gov.dwp.dla.mobility.lower_min_age", "2001-04-09", 5), + ], +) +def test_raw_age_dates_and_supported_annual_parameters(path, first, minimum): + parameter_file = ( + Path(__file__).resolve().parents[1] + / "parameters" + / (path.replace(".", "/") + ".yaml") + ) + raw = load_parameter_file(str(parameter_file), name=path) + assert raw.values_list[-1].instant_str == first + assert raw(first) == minimum + assert raw("2040-01-01") == minimum + # CA's first date is model coverage, not asserted legal commencement. + # Statutory CSP dates do not expand the existing annual replacement rule. + system = CountryTaxBenefitSystem() + for year in [2015, 2024, 2025, 2026, 2040]: + node = system.parameters(str(year)) + for part in path.split("."): + node = getattr(node, part) + assert node == minimum + + +@pytest.mark.parametrize("year", [2015, 2024, 2025]) +def test_age_gate_keeps_supported_annual_csp_and_ca_routing(year): + inputs = situation({"age": 15, "care_hours": 35}, "SCOTLAND") + for person in inputs["people"].values(): + for name, value in person.items(): + person[name] = {year: value[YEAR]} + inputs["households"]["home"]["country"] = {year: "SCOTLAND"} + sim = Simulation(situation=inputs) + assert sim.calculate("carers_allowance", year)[1] == 0 + assert sim.calculate("carer_support_payment", year)[1] == 0 + + +@pytest.mark.parametrize( + "parameter,raised,person,country,output,baseline,other_person,other_country,other_output,other_cash", + [ + ( + "gov.dwp.carers_allowance.min_age", + 18, + {"age": 17, "care_hours": 35}, + "ENGLAND", + "carers_allowance", + 4495.40, + {"age": 17, "care_hours": 35}, + "SCOTLAND", + "carer_support_payment", + 4495.40, + ), + ( + "gov.social_security_scotland.carer_support_payment.min_age", + 18, + {"age": 17, "care_hours": 35}, + "SCOTLAND", + "carer_support_payment", + 4495.40, + {"age": 17, "care_hours": 35}, + "ENGLAND", + "carers_allowance", + 4495.40, + ), + ( + "gov.dwp.dla.mobility.higher_min_age", + 4, + {"age": 3, "dla_m_category": "HIGHER"}, + "ENGLAND", + "dla_m", + 4160, + {"age": 5, "dla_m_category": "LOWER"}, + "ENGLAND", + "dla_m", + 1575.60, + ), + ( + "gov.dwp.dla.mobility.lower_min_age", + 6, + {"age": 5, "dla_m_category": "LOWER"}, + "ENGLAND", + "dla_m", + 1575.60, + {"age": 3, "dla_m_category": "HIGHER"}, + "ENGLAND", + "dla_m", + 4160, + ), + ], +) +def test_each_minimum_age_reform_moves_only_its_cash_boundary( + parameter, + raised, + person, + country, + output, + baseline, + other_person, + other_country, + other_output, + other_cash, +): + original = Simulation(situation=situation(person, country)) + reformed = Simulation( + situation=situation(person, country), reform={parameter: raised} + ) + assert original.calculate(output, YEAR)[1] == pytest.approx(baseline, abs=0.01) + assert reformed.calculate(output, YEAR)[1] == 0 + countercontrol = Simulation( + situation=situation(other_person, other_country), reform={parameter: raised} + ) + assert countercontrol.calculate(other_output, YEAR)[1] == pytest.approx( + other_cash, abs=0.01 + ) + + +@pytest.mark.parametrize( + "input_name,output,amount,country,age", + [ + ("carers_allowance", "carers_allowance", 123, "ENGLAND", 15), + ("carers_allowance_pre_overlap", "carers_allowance", 234, "ENGLAND", 15), + ("carer_support_payment", "carer_support_payment", 345, "SCOTLAND", 15), + ( + "carer_support_payment_pre_overlap", + "carer_support_payment", + 456, + "SCOTLAND", + 15, + ), + ("dla_m", "dla", 567, "ENGLAND", 1), + ], +) +def test_explicit_amount_overrides_keep_the_generic_input_contract( + input_name, output, amount, country, age +): + # A supplied amount is intentionally an override, not entitlement proof. + sim = Simulation(situation=situation({"age": age, input_name: amount}, country)) + assert sim.calculate(output, YEAR)[1] == amount + + +def test_missing_age_keeps_the_existing_default_and_none_category_pays_nothing(): + sim = Simulation( + situation=situation({"care_hours": 35, "dla_m_category": "HIGHER"}) + ) + assert sim.calculate("age", YEAR)[1] == 40 + assert sim.calculate("carers_allowance", YEAR)[1] == pytest.approx( + 4495.40, abs=0.01 + ) + assert sim.calculate("dla_m", YEAR)[1] == 4160 + none = Simulation(situation=situation({"age": 40, "dla_m_category": "NONE"})) + assert none.calculate("dla_m", YEAR)[1] == 0 + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_each_persons_age_controls_cash_across_families_and_request_order(reverse): + people, benunits, households = {}, {}, {} + specs = [ + ("english", "ENGLAND", 15, 3), + ("scottish", "SCOTLAND", 16, 2), + ] + for family, country, carer_age, child_age in specs: + names = [family + suffix for suffix in ["_caree", "_carer", "_child"]] + facts = [ + {"age": 50, "pip_dl_category": "STANDARD"}, + {"age": carer_age, "care_hours": 35}, + {"age": child_age, "dla_m_category": "HIGHER"}, + ] + for name, fact in zip(names, facts): + people[name] = {key: {YEAR: value} for key, value in fact.items()} + members = list(reversed(names)) if reverse else names + benunits[family] = {"members": members} + households[family] = {"members": members, "country": {YEAR: country}} + if reverse: + people = dict(reversed(list(people.items()))) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + if reverse: + sim.calculate("is_entitled_to_carer_benefit", YEAR) + for variable, expected in [ + ("carers_allowance", {}), + ("carer_support_payment", {"scottish_carer": 4495.40}), + ("dla", {"english_child": 4160}), + ]: + values = dict(zip(sim.populations["person"].ids, sim.calculate(variable, YEAR))) + for name, value in values.items(): + assert value == pytest.approx(expected.get(name, 0), abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 6bca6f6b93..0227da876a 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -22,6 +22,7 @@ def formula(person, period, parameters): csp_replaces_ca = period.start.year >= 2025 receives_ca = person("carers_allowance_reported", period) > 0 ca = parameters(period).gov.dwp.carers_allowance + meets_age_condition = person("age", period) >= ca.min_age weekly_care_hours = person("care_hours", period) meets_work_condition = weekly_care_hours >= ca.min_hours would_claim = person("would_claim_carers_allowance", period) @@ -36,5 +37,6 @@ def formula(person, period, parameters): & (meets_work_condition | receives_ca) & ~gainfully_employed & would_claim + & meets_age_condition ) return eligible * ca.rate * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/dla/dla_m.py b/policyengine_uk/variables/gov/dwp/dla/dla_m.py index 742d4ea8b1..de0f768f4e 100644 --- a/policyengine_uk/variables/gov/dwp/dla/dla_m.py +++ b/policyengine_uk/variables/gov/dwp/dla/dla_m.py @@ -7,16 +7,21 @@ class dla_m(Variable): definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/73", + "https://www.legislation.gov.uk/ukpga/1992/7/section/73", + ) def formula(person, period, parameters): dla_m = parameters(period).gov.dwp.dla.mobility category = person("dla_m_category", period) categories = category.possible_values + age = person("age", period) return ( select( [ - category == categories.HIGHER, - category == categories.LOWER, + (category == categories.HIGHER) & (age >= dla_m.higher_min_age), + (category == categories.LOWER) & (age >= dla_m.lower_min_age), category == categories.NONE, ], [ diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py index 8826fc1a5b..4b77d82520 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py @@ -17,6 +17,7 @@ class carer_support_payment_pre_overlap(Variable): reference = ( "https://www.legislation.gov.uk/asp/2018/9/part/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/3", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", ) @@ -24,11 +25,16 @@ def formula(person, period, parameters): in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" csp_in_effect = period.start.year >= 2025 csp = parameters(period).gov.social_security_scotland.carer_support_payment + meets_age_condition = person("age", period) >= csp.min_age weekly_care_hours = person("care_hours", period) meets_hours = weekly_care_hours >= csp.min_hours receives_ca = person("carers_allowance_reported", period) > 0 would_claim = person("would_claim_carers_allowance", period) eligible = ( - in_scotland & csp_in_effect & (meets_hours | receives_ca) & would_claim + in_scotland + & csp_in_effect + & (meets_hours | receives_ca) + & would_claim + & meets_age_condition ) return eligible * csp.rate * WEEKS_IN_YEAR From ecb9c8a8de1d01aaa501ba472d5f4bdbbe7f319e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:11:40 +0100 Subject: [PATCH 033/141] Fix participation periods, eligible bins and local random draws --- changelog.d/1836.fixed.md | 1 + docs/book/usage/dynamics.md | 60 +++- policyengine_uk/dynamics/participation.py | 77 ++--- .../tests/test_participation_correctness.py | 263 ++++++++++++++++++ 4 files changed, 360 insertions(+), 41 deletions(-) create mode 100644 changelog.d/1836.fixed.md create mode 100644 policyengine_uk/tests/test_participation_correctness.py diff --git a/changelog.d/1836.fixed.md b/changelog.d/1836.fixed.md new file mode 100644 index 0000000000..872e66d6cc --- /dev/null +++ b/changelog.d/1836.fixed.md @@ -0,0 +1 @@ +Use the requested year and eligible-only spillover bins in participation dynamics, preserving seeded MT19937 draws without changing global NumPy state. diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index a29777a531..33fd3855ad 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -10,7 +10,11 @@ PolicyEngine UK implements the Office for Budget Responsibility's labour supply **Extensive margin (participation)** - Whether people choose to work at all. Changes in the financial reward from working affect decisions to enter or leave employment. -Both responses depend on elasticities that vary by demographic group, following empirical evidence on how different populations respond to policy changes. +Both responses depend on elasticities that vary by demographic group. The +current `apply_dynamics` coordinator runs progression only: it leaves +participation disabled and returns `participation=None`. The separate +participation function remains available for controlled development work; the +coordinator does not combine its response with progression. ## Basic dynamics calculation @@ -46,7 +50,10 @@ print( f"Substitution effect FTEs: {dynamics.fte_impacts.substitution_response_ftes:,.0f}" ) print(f"Income effect FTEs: {dynamics.fte_impacts.income_response_ftes:,.0f}") -print(f"Participation FTEs: {dynamics.fte_impacts.participation_response_ftes:,.0f}") +if dynamics.fte_impacts.participation_response_ftes is not None: + print( + f"Participation FTEs: {dynamics.fte_impacts.participation_response_ftes:,.0f}" + ) print(f"Total FTE change: {dynamics.fte_impacts.ftes:,.0f}") ``` @@ -64,14 +71,50 @@ print(f"Average hours change: {progression['total_response_ftes'].mean():.3f} FT # Access participation responses (extensive margin) participation = dynamics.participation -print(f"Employment change: {participation['participation_change'].sum():,.0f}") +if participation is not None: + print(f"Employment change: {participation['participation_change'].sum():,.0f}") # FTE impacts provide summary statistics fte = dynamics.fte_impacts print(f"Total labour supply response: {fte.ftes:,.0f} FTEs") ``` -The progression DataFrame contains individual-level responses including wage changes, elasticities applied, and resulting employment income adjustments. The participation DataFrame shows employment status changes and associated FTE impacts. +The progression DataFrame contains individual-level responses including wage +changes, elasticities applied and resulting employment income adjustments. +Participation results are absent from the current coordinator. + +### Separate participation calls + +`policyengine_uk.dynamics.participation.apply_participation_responses` accepts +an explicit calculation year. Its demographics, adult positions, gain-to-work +counterfactuals and earnings/hours writeback use that year. Direct callers of +`calculate_participation_elasticities` may omit its optional year to retain +the simulation's default-period behaviour. These paths expect the weighted +`Microsimulation` calculation interface; a supplied situation can exercise +them without a population dataset. + +Spillovers retain 20 equal-width bins, now bounded by eligible response +candidates only. Adding excluded people cannot move those bounds. The bins, +unweighted within-bin averages, spillover factor and replacement-rate +transformation are inherited model approximations, not methods specified by the +OBR note. The existing exclusions, coefficients, age-18 grouping, wage imputation +and full-time-equivalent quintile placement are unchanged. + +A local `numpy.random.RandomState` preserves the legacy MT19937 stream for a +given seed, without changing NumPy's process-global random state. Draws are +consumed in ascending worker-row order, followed by ascending non-worker-row +order, including candidates with zero probabilities or nonpositive imputed +wages. The two draw loops are vectorized with the same candidate order and +storage precision. This does not make stochastic assignments invariant to row +permutations. New entrants' weekly hours are converted to annual hours using the +framework's `WEEKS_IN_YEAR` constant. + +These corrections do not activate participation in the coordinator, validate +calibration or certify whole-population speed. Repeated wage imputation and +income recomputations remain, and broader performance/caching work stays open +in [issue1836](https://github.com/PolicyEngine/policyengine-uk/issues/1836). +The separate cohabiting-group correction in PR2445 also needs to be retained at +eventual integration; this branch does not certify that combined landing. ## Analysing distributional effects @@ -155,7 +198,10 @@ print(f"OBR estimate: 94,000") print(f"Difference: {(dynamics.fte_impacts.ftes - 94_000):+,.0f}") ``` -Small differences from OBR estimates can arise from data vintage, population projections, or minor methodological variations. +This example runs the current progression-only coordinator. It is not a +validation of the disabled participation route or a promise of agreement with +the OBR total. Data, grouping, imputation and calibration choices also affect +the comparison. ## Elasticity-based responses @@ -238,4 +284,6 @@ When using dynamics, be aware of these limitations: - Self-employed workers are excluded as their labour supply is harder to model - The model doesn't capture long-term effects like human capital accumulation -For most tax and benefit reforms, these limitations don't significantly affect the results. The framework provides robust estimates aligned with fiscal policy analysis standards. \ No newline at end of file +The size of these limitations has not been established for every reform. +Participation calibration and broader performance work remain outside these +implementation corrections. \ No newline at end of file diff --git a/policyengine_uk/dynamics/participation.py b/policyengine_uk/dynamics/participation.py index 5e4eb50a08..349d887fb0 100644 --- a/policyengine_uk/dynamics/participation.py +++ b/policyengine_uk/dynamics/participation.py @@ -22,6 +22,7 @@ def calculate_participation_elasticities( sim: Simulation, earnings_quintile: np.ndarray, + year: int = None, ) -> np.ndarray: """Calculate labour force participation elasticities by demographic group. @@ -31,23 +32,24 @@ def calculate_participation_elasticities( Args: sim: PolicyEngine simulation object earnings_quintile: Array indicating earnings quintile (1-5) for each person + year: Requested year, or the simulation default if omitted Returns: Array of participation elasticities for each person """ # Get demographic characteristics - gender = sim.calculate("gender") - is_married = sim.calculate("is_married", map_to="person") - composition = benunit_age_18_composition(sim) + gender = sim.calculate("gender", year) + is_married = sim.calculate("is_married", year, map_to="person") + composition = benunit_age_18_composition(sim, year) has_children = composition["count_under_18"].values > 0 youngest_child_age = composition["youngest_under_18_age"].values is_single = ~is_married # Get partner employment status for married individuals benunit_count_adults = composition["count_aged_18_or_over"].values - employment_income = sim.calculate("employment_income") - benunit_id = sim.calculate("benunit_id", map_to="person") - adult_index = sim.calculate("adult_index") + employment_income = sim.calculate("employment_income", year) + benunit_id = sim.calculate("benunit_id", year, map_to="person") + adult_index = sim.calculate("adult_index", year) # Create DataFrame for efficient partner employment calculation df = pd.DataFrame( @@ -337,7 +339,7 @@ def calculate_gain_to_work( employment_income = sim.calculate("employment_income", year) hours_worked = sim.calculate("hours_worked", year) household_net_income = sim.calculate("household_net_income", year, map_to="person") - adult_index = sim.calculate("adult_index") + adult_index = sim.calculate("adult_index", year) # Impute wages for non-workers if requested if impute_nonworker_wages: @@ -579,8 +581,8 @@ def apply_participation_responses( if sim.baseline is None: return pd.DataFrame() - # Set random seed for reproducibility - np.random.seed(random_seed) + # Preserve the legacy MT19937 stream without mutating process-global state. + rng = np.random.RandomState(random_seed) # Calculate excluded individuals from .labour_supply import calculate_excluded_from_labour_supply_responses @@ -589,7 +591,7 @@ def apply_participation_responses( # Get employment status employment_income = sim.calculate("employment_income", year) - adult_index = sim.calculate("adult_index") + adult_index = sim.calculate("adult_index", year) eligible = ~excluded & (adult_index > 0) & (adult_index <= count_adults) currently_working = (employment_income > 0) & eligible currently_not_working = (employment_income == 0) & eligible @@ -626,7 +628,7 @@ def apply_participation_responses( sim, year, hours_for_new_entrants, random_seed ) elasticities_wrt_income = calculate_participation_elasticities( - sim, earnings_quintile + sim, earnings_quintile, year ) # Transform elasticities from w.r.t. in-work income to w.r.t. gain-to-work @@ -655,8 +657,13 @@ def apply_participation_responses( # When workers have worse incentives (negative GTW change), this creates a "deficit" # that pushes non-workers further away from employment - # Group people by elasticity group for surplus calculation - elasticity_groups = pd.cut(elasticities, bins=20, labels=False, duplicates="drop") + # Excluded records must not change the bin bounds for response candidates. + # Twenty equal-width bins remain an inherited spillover approximation. + elasticity_groups = np.full(elasticities.shape, np.nan) + if eligible.any(): + elasticity_groups[eligible] = pd.cut( + elasticities[eligible], bins=20, labels=False, duplicates="drop" + ) surplus_participation_change = np.zeros_like(direct_participation_change) @@ -693,29 +700,29 @@ def apply_participation_responses( new_employment_income = employment_income.copy() participation_response = np.zeros_like(employment_income, dtype=bool) - # For currently working individuals: chance of leaving work - for i in np.where(currently_working)[0]: - # Negative participation change means lower probability of working - exit_probability = max( - 0, -participation_change[i] - ) # Only consider negative changes - if np.random.random() < exit_probability: - new_employment_income[i] = 0 - participation_response[i] = True # Exited work - - # For currently non-working individuals: chance of entering work + # Draw for every worker first, then every non-worker, in ascending row order. + # Keep zero-probability and nonpositive-wage candidates in the stream. + workers = np.flatnonzero(currently_working) + exited = workers[ + rng.random_sample(len(workers)) < np.maximum(0, -participation_change[workers]) + ] + new_employment_income[exited] = 0 + participation_response[exited] = True + imputed_wages = impute_wages_for_nonworkers(sim, year, hours_for_new_entrants) - for i in np.where(currently_not_working)[0]: - # Positive participation change means higher probability of working - entry_probability = max( - 0, participation_change[i] - ) # Only consider positive changes - if np.random.random() < entry_probability and imputed_wages[i] > 0: - # Cast for the same reason as the in-work assignment above. - new_employment_income[i] = new_employment_income.dtype.type( - imputed_wages[i] - ) - participation_response[i] = True # Entered work + nonworkers = np.flatnonzero(currently_not_working) + entered = nonworkers[ + ( + rng.random_sample(len(nonworkers)) + < np.maximum(0, participation_change[nonworkers]) + ) + & (imputed_wages[nonworkers] > 0) + ] + # Match the legacy scalar assignment's model-storage precision. + new_employment_income[entered] = imputed_wages[entered].astype( + new_employment_income.dtype + ) + participation_response[entered] = True # Update simulation with new employment incomes sim.set_input("employment_income", year, new_employment_income) diff --git a/policyengine_uk/tests/test_participation_correctness.py b/policyengine_uk/tests/test_participation_correctness.py new file mode 100644 index 0000000000..99b0a781fc --- /dev/null +++ b/policyengine_uk/tests/test_participation_correctness.py @@ -0,0 +1,263 @@ +"""Participation contracts; controlled incentives are not population predictions.""" + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.dynamics import participation as p +from policyengine_uk.model_api import WEEKS_IN_YEAR + + +class PeriodSimulation: + """Small mutable simulation protocol, with observable cash and year storage.""" + + def __init__(self, pay, *, default=2025, roles=None, dtype=np.float32): + n = len(pay) + self.default = default + self.baseline = None + self.pay = {year: pd.Series(pay, dtype=dtype) for year in (2025, 2027)} + self.hours = { + year: pd.Series(np.where(np.array(pay) > 0, 1000, 0), dtype=dtype) + for year in (2025, 2027) + } + self.roles = roles or {year: np.ones(n, dtype=int) for year in (2025, 2027)} + self.populations = {"benunit": SimpleNamespace(members_entity_id=np.arange(n))} + + def calculate(self, name, period=None, **kwargs): + year = period or self.default + if name == "employment_income": + return self.pay[year].copy() + if name == "hours_worked": + return self.hours[year].copy() + if name == "adult_index": + return pd.Series(self.roles[year]) + if name == "household_net_income": + return pd.Series(np.repeat(500 + self.pay[year].sum(), len(self.pay[year]))) + if name == "household_weight": + return pd.Series(np.ones(len(self.pay[year]))) + raise AssertionError(name) + + def set_input(self, name, year, values): + target = self.pay if name == "employment_income" else self.hours + target[year] = pd.Series(np.asarray(values), dtype=target[year].dtype) + + def reset_calculations(self): + pass + + +def controlled( + monkeypatch, + pay, + elasticities, + changes, + *, + excluded=None, + imputed=None, + dtype=np.float32, +): + """Hold incentives fixed while executing the real response/bin/draw/writeback API.""" + sim = PeriodSimulation(pay, dtype=dtype) + sim.baseline = PeriodSimulation(pay, dtype=dtype) + n = len(pay) + elasticities = np.asarray(elasticities, dtype=float) + changes = np.asarray(changes, dtype=float) + if excluded is None: + excluded = np.zeros(n, dtype=bool) + if imputed is None: + imputed = np.repeat(5000.123456, n) + from policyengine_uk.dynamics import labour_supply + + monkeypatch.setattr( + labour_supply, + "calculate_excluded_from_labour_supply_responses", + lambda *args: np.asarray(excluded), + ) + monkeypatch.setattr(p, "calculate_earnings_quintile", lambda *args: np.ones(n)) + monkeypatch.setattr( + p, + "calculate_participation_elasticities", + lambda sim, quintile, year=None: elasticities.copy(), + ) + monkeypatch.setattr( + p, "impute_wages_for_nonworkers", lambda *args: np.asarray(imputed) + ) + + def gain(source, *args, **kwargs): + return pd.DataFrame( + { + "gain_to_work": 100 * (1 + changes) + if source is sim + else np.repeat(100.0, n), + "in_work_income": np.repeat(100.0, n), + "out_of_work_income": np.zeros(n), + } + ) + + monkeypatch.setattr(p, "calculate_gain_to_work", gain) + return sim + + +def test_requested_year_changes_real_parent_elasticity_and_cash(monkeypatch): + """Real public-input Simulation, isolated hypothetical loss of work incentive.""" + sim = Microsimulation( + situation={ + "people": { + "parent": { + "age": {"2025": 40, "2027": 42}, + "gender": "FEMALE", + "employment_income": {"2025": 20000, "2027": 20000}, + "hours_worked": {"2025": 1000, "2027": 1000}, + }, + "child": {"age": {"2025": 2, "2027": 4}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": {"home": {"members": ["parent", "child"]}}, + }, + reform={"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.19}}, + ) + + def gain(source, *args, **kwargs): + return pd.DataFrame( + { + "gain_to_work": [0.0, 0.0] if source is sim else [100.0, 100.0], + "in_work_income": [100.0, 100.0], + "out_of_work_income": [0.0, 0.0], + } + ) + + monkeypatch.setattr(p, "calculate_gain_to_work", gain) + result = p.apply_participation_responses(sim, 2027, count_adults=1, random_seed=10) + assert result["participation_elasticity"].iloc[0] == pytest.approx(0.932) + assert result["new_employment_income"].iloc[0] == 0 + assert sim.calculate("hours_worked", 2027).iloc[0] == 0 + assert sim.calculate("employment_income", 2025).iloc[0] == 20000 + assert np.asarray(sim.baseline.calculate("employment_income", 2027))[0] == 20000 + + +@pytest.mark.parametrize("outlier", [10.0, -10.0]) +def test_excluded_people_cannot_move_eligible_bins_or_surplus(monkeypatch, outlier): + sim = controlled(monkeypatch, [100, 0, 100], [0.1, 0.2, 0.3], [0.5, 0, 0]) + before = p.apply_participation_responses(sim, 2025) + sim = controlled( + monkeypatch, + [100, 0, 100, 999], + [0.1, 0.2, 0.3, outlier], + [0.5, 0, 0, 100], + excluded=[False, False, False, True], + ) + after = p.apply_participation_responses(sim, 2025) + for name in ( + "elasticity_group", + "surplus_participation_change", + "participation_change", + "new_employment_income", + "participation_response", + ): + np.testing.assert_array_equal(after[name], before[name]) + np.testing.assert_array_equal(before["surplus_participation_change"], [0, 0, 0]) + assert sim.pay[2025].iloc[3] == 999 + assert sim.hours[2025].iloc[3] == 1000 + + +def test_response_preserves_global_numpy_random_state(monkeypatch): + sim = controlled(monkeypatch, [100, 0], [0.1, 0.2], [0, 0]) + original = np.random.get_state() + try: + np.random.seed(987) + state = np.random.get_state() + p.apply_participation_responses(sim, 2025, random_seed=42) + after = np.random.get_state() + assert state[0] == after[0] + np.testing.assert_array_equal(state[1], after[1]) + assert state[2:] == after[2:] + finally: + np.random.set_state(original) + + +def test_requested_year_adult_role_controls_gain_and_restores_income(): + sim = PeriodSimulation([100, 0], roles={2025: [0, 1], 2027: [1, 0]}) + result = p.calculate_gain_to_work( + sim, 2027, count_adults=1, impute_nonworker_wages=False + ) + np.testing.assert_array_equal(result["gain_to_work"], [100, 0]) + np.testing.assert_array_equal(sim.pay[2027], [100, 0]) + np.testing.assert_array_equal(sim.pay[2025], [100, 0]) + + +@pytest.mark.parametrize("seed", [0, 1, 42, 123]) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_local_draws_match_independent_legacy_loop_and_hours(monkeypatch, seed, dtype): + pay = np.array([100, 0, 200, 0, 300, 0, 400, 0], dtype=dtype) + hours = np.where(pay > 0, 1000, 0).astype(dtype) + elasticities = np.arange(1, 9) / 10 + change = np.array([0, 1, -1, 0.5, -0.25, 2, 0, 1.0]) + imputed = np.array([0, 5000.123456, 0, 6000.321987, 0, 0, 0, -1.0]) + sim = controlled( + monkeypatch, + pay, + elasticities, + change / elasticities, + imputed=imputed, + dtype=dtype, + ) + expected = pay.copy() + flags = np.zeros(len(pay), dtype=bool) + rng = np.random.RandomState(seed) + # Independent scalar reference preserves the historical candidate draw order, + # including zero probabilities and wages that cannot produce entry. + for index, income in enumerate(pay): + if income > 0: + if rng.random_sample() < max(0, -change[index]): + expected[index] = 0 + flags[index] = True + for index, income in enumerate(pay): + if income == 0: + if rng.random_sample() < max(0, change[index]) and imputed[index] > 0: + expected[index] = dtype(imputed[index]) + flags[index] = True + expected_hours = hours.copy() + expected_hours[(pay == 0) & (expected > 0)] = 977.6 + expected_hours[(pay > 0) & (expected == 0)] = 0 + assert WEEKS_IN_YEAR == 52 + result = p.apply_participation_responses(sim, 2025, random_seed=seed) + np.testing.assert_allclose( + result["direct_participation_change"], change, rtol=0, atol=1e-15 + ) + np.testing.assert_array_equal(result["surplus_participation_change"], np.zeros(8)) + np.testing.assert_array_equal(sim.pay[2025], expected) + np.testing.assert_array_equal(result["participation_response"], flags) + np.testing.assert_array_equal(sim.hours[2025], expected_hours) + assert sim.pay[2025].dtype == np.dtype(dtype) + np.testing.assert_array_equal(sim.baseline.pay[2025], pay) + + +def test_no_baseline_is_an_early_noop(): + sim = PeriodSimulation([100, 0]) + assert p.apply_participation_responses(sim, 2027).empty + np.testing.assert_array_equal(sim.pay[2027], [100, 0]) + + +def test_no_eligible_people_and_constant_bins_are_safe(monkeypatch): + sim = controlled(monkeypatch, [100, 0], [0.2, 0.2], [1, -1], excluded=[True, True]) + assert p.apply_participation_responses(sim, 2025).empty + np.testing.assert_array_equal(sim.pay[2025], [100, 0]) + sim = controlled(monkeypatch, [100, 0], [0.2, 0.2], [0, 0]) + result = p.apply_participation_responses(sim, 2025) + assert result["elasticity_group"].nunique() == 1 + np.testing.assert_array_equal(result["participation_change"], [0, 0]) + + +def test_omitted_year_retains_default_elasticity_contract(): + sim = Simulation( + situation={ + "people": {"adult": {"age": 40, "gender": "MALE"}}, + "benunits": {"b": {"members": ["adult"]}}, + "households": {"h": {"members": ["adult"]}}, + } + ) + np.testing.assert_array_equal( + p.calculate_participation_elasticities(sim, np.array([3])), [0.136] + ) From 3a195c3920f642848befa4429e423d991ac818be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:13:47 +0100 Subject: [PATCH 034/141] Restrict ordinary ATI pension uplift to relief at source --- changelog.d/2486.fixed.md | 1 + .../adjusted_total_income_pension_relief.yaml | 426 ++++++++++++++++++ .../reliefs/adjusted_total_income.py | 26 +- 3 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2486.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml diff --git a/changelog.d/2486.fixed.md b/changelog.d/2486.fixed.md new file mode 100644 index 0000000000..18f02a0b54 --- /dev/null +++ b/changelog.d/2486.fixed.md @@ -0,0 +1 @@ +- Limited the ordinary income-tax loss-cap pension uplift above earnings to relief-at-source contributions. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml new file mode 100644 index 0000000000..8760447c9f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml @@ -0,0 +1,426 @@ +# Ordinary loss-cap ATI (ITA 2007 s.24A(8)), preserving the model's own earnings base. +# FA 2004 ss.190(3),191(7): only relief at source can give the basic uplift above +# relevant earnings. Existing inputs treat gross personal contributions as RAS +# and reported employee contributions as net pay; no new scheme-input model. +# Primary 2026 control: wages2,000 + dividends250,000 - relievednetpay2,000 +# => ATI250,000; cap62,500; ANI187,500. All rates/thresholds are unchanged +# model controls, not a new rate audit. Final dividend tax is: +# (37,700-500)*.1075 + (125,140-37,700)*.3575 + (187,500-125,140)*.3935 +# =59,797.46. No eligibility, tax base, relief or final tax is supplied as input. + +- name: Net pay within earnings preserves the final tax control + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 2000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: &id001 + income_tax: 59797.46 + employment_income: 2000 + pension_contributions_relief: 2000 + adjusted_total_income: 250000 + income_tax_relief_cap: 62500 + trade_loss_relief_against_general_income: 62500 + net_income: 189500 + adjusted_net_income: 187500 + personal_allowance: 0 + personal_pension_contributions_tax: 0 +- name: Pure net pay above earnings cannot use the RAS basic uplift + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: *id001 +- name: Mixed payments limit the basic uplift to actual gross RAS + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 249400 + income_tax_relief_cap: 62350 + pension_contributions_relief: 2600 +- name: No member contribution leaves adjusted total income unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 252000 + income_tax_relief_cap: 63000 +- name: Relief cannot exceed the member contributions actually paid + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 1000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 251000 + pension_contributions_relief: 1000 +- name: Pure RAS retains relief up to the basic amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 248400 + income_tax_relief_cap: 62100 + pension_contributions_relief: 3600 +- name: Mixed contributions below earnings remain fully relieved + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 1000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250400 + pension_contributions_relief: 1600 +- name: The age 75 boundary excludes member relief + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 75 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + state_pension_reported: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 252000 + pension_contributions_relief: 0 +- name: Earnings above the basic amount retain ordinary net pay relief + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 10000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 5000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 255000 + pension_contributions_relief: 5000 +- name: ATI retains statutory pay in its own relevant earnings base + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 1000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 4000 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + statutory_maternity_pay: 3000 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250000 + pension_contributions_relief: 3600 +- name: The basic amount remains reformable for actual RAS contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 6000 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount: 5000 + output: + adjusted_total_income: 247000 + pension_contributions_relief: 5000 +- name: Relieved RAS cannot make adjusted total income negative + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 0 + dividend_income: 0 + trading_loss: 0 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 0 + income_tax: 0 +- name: Each person keeps their own RAS and age limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + p0: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + p1: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + p2: + age: 75 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + state_pension_reported: 0 + benunits: + b0: + members: + - p0 + b1: + members: + - p1 + b2: + members: + - p2 + households: + h0: + members: + - p0 + region: LONDON + h1: + members: + - p1 + region: LONDON + h2: + members: + - p2 + region: LONDON + output: + adjusted_total_income: + - 250000 + - 249400 + - 252000 + pension_contributions_relief: + - 2000 + - 2600 + - 0 +- name: ATI first and final tax first give the same corrected cash + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250000 + income_tax_relief_cap: 62500 + adjusted_net_income: 187500 + income_tax: 59797.46 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 959562f123..810a99cb83 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -11,10 +11,12 @@ class adjusted_total_income(Variable): "components, with losses brought forward (loss_relief, a Step 2 " "relief the model deducts from trading profits) added back. Steps 3 " "and 4 deduct the pension contributions given relief (FA 2004 " - "ss.192-194): the person's own contributions up to the greater of " - "their relevant UK earnings (s.189(2): taxable employment income, " - "including benefits and statutory pay, and trading profits) and the " - "basic amount (s.190), none from " + "ss.192-194): the person's own contributions up to relevant UK " + "earnings (s.189(2): taxable employment income, including benefits " + "and statutory pay, and trading profits). The basic amount can " + "increase that limit only through relief at source (ss.190(3), " + "191(7)), which the model represents with gross personal pension " + "contributions; employee contributions represent net pay. None is relieved from " "age 75 (s.188(3)(a)). The annual allowance limits tax-relieved " "saving through a charge, not the relief itself, so it is not applied " "here. Step 2 adds back payroll giving, which the model does not have." @@ -30,6 +32,10 @@ class adjusted_total_income(Variable): title="Finance Act 2004 ss. 188-190", href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", ), + dict( + title="Finance Act 2004 s. 191(7)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/191", + ), ] def formula(person, period, parameters): @@ -56,9 +62,17 @@ def formula(person, period, parameters): relevant_earnings = ( person("taxable_employment_income", period) + profits_before_carry_forward ) - relief_limit = max_( - p.income_tax.reliefs.pension_contribution.basic_amount, relevant_earnings + # FA 2004 ss.190(3),191(7): only actual relief-at-source contributions + # can use the basic-amount increase above relevant UK earnings. + basic_amount_increase = min_( + max_( + 0, + p.income_tax.reliefs.pension_contribution.basic_amount + - relevant_earnings, + ), + person("personal_pension_contributions", period), ) + relief_limit = relevant_earnings + basic_amount_increase under_age_limit = ( person("age", period) < p.pensions.pension_contributions_relief_age_limit ) From bfc18db21f12c02b008345f1c8ed71cdde357c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:47:08 +0100 Subject: [PATCH 035/141] Use public Core APIs for UK parameter caches --- changelog.d/2175.changed.md | 1 + docs/book/policy/model-baseline.md | 6 + policyengine_uk/tax_benefit_system.py | 64 +++--- .../tests/test_parameter_cache_apis.py | 205 ++++++++++++++++++ 4 files changed, 239 insertions(+), 37 deletions(-) create mode 100644 changelog.d/2175.changed.md create mode 100644 policyengine_uk/tests/test_parameter_cache_apis.py diff --git a/changelog.d/2175.changed.md b/changelog.d/2175.changed.md new file mode 100644 index 0000000000..041e0d1f84 --- /dev/null +++ b/changelog.d/2175.changed.md @@ -0,0 +1 @@ +Use public Core operations for UK parameter initialization and dated-cache clearing while preserving parameter and reform behavior. diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 29f7dafb3c..138f52dd4b 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -122,3 +122,9 @@ Notable exclusions: - **Some devolved tax policies**: Beyond property transaction taxes, other devolved policies may have limited coverage All parameter values include references to primary legislation and can be found in the [PolicyEngine UK parameters directory](https://github.com/PolicyEngine/policyengine-uk/tree/master/policyengine_uk/parameters). + +## Parameter cache ownership + +The UK tax-benefit system initializes Core state and installs parameter trees through Core's public operations. The existing `reset_parameter_caches()` method clears dated views on the system, the root and descendant parameter nodes without replacing the live tree or its leaves. Parameter processing retains its statutory timelines, baseline, structural reform nodes and rollback behavior; this change does not alter policy values or reform timing. + +The selected operations are available at the existing Core minimum version, 3.32.27, and in the locked 3.33.2 release, so this parameter-only migration does not change the dependency or lockfile. It implements part of [issue #2175](https://github.com/PolicyEngine/policyengine-uk/issues/2175). Supplied-input tracking, model-value provenance and clone, deletion and rebuild compatibility remain separate work. Those compatibility paths are retained, including the distinction between an amount supplied by a user and one stored by the model. diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index cfd1f67801..9d49758d48 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -4,7 +4,7 @@ from collections import OrderedDict from dataclasses import replace from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple # PolicyEngine core imports from policyengine_core.parameters import Parameter, ParameterNode @@ -81,6 +81,14 @@ logger = logging.getLogger(__name__) + +def _install_parameters(system, parameters: ParameterNode) -> None: + # A Core Reform delegates unknown instance methods to its baseline. Keep + # this orchestration local and call the public base operation explicitly: + # Reform.modify_parameters would also start from the baseline's tree. + TaxBenefitSystem.modify_parameters(system, lambda _: parameters) + + # Module constants COUNTRY_DIR = Path(__file__).parent DEFAULT_DATASET_ENV_VAR = "POLICYENGINE_UK_DEFAULT_DATASET" @@ -239,11 +247,13 @@ def neutralize_variable(self, variable_name: str) -> None: super().neutralize_variable(part) def reset_parameter_caches(self): - """Reset all caches in the tax-benefit system.""" - self._parameters_at_instant_cache = {} - for parameter in self.parameters.get_descendants(): - parameter._at_instant_cache = {} - self.parameters._at_instant_cache = {} + """Clear dated views through their Core owners, retaining the tree.""" + for node in [self.parameters, *self.parameters.get_descendants()]: + if isinstance(node, ParameterNode): + # The public operation clears this node and its ancestors; + # root-only clearing would leave cached descendants behind. + node.clear_parent_cache() + _install_parameters(self, self.parameters) def reset_parameters(self) -> None: """Reset parameters by reloading from the parameters directory. @@ -253,7 +263,6 @@ def reset_parameters(self) -> None: them survive the reload. Only a processed tree is kept, and a second reset before processing keeps the first one. """ - self._parameters_at_instant_cache = {} current = getattr(self, "parameters", None) if ( getattr(self, "_parameters_before_reset", None) is None @@ -262,7 +271,7 @@ def reset_parameters(self) -> None: ): self._parameters_before_reset = current self.load_parameters(self.parameters_dir) - self.parameters = add_removed_parameter_aliases(self.parameters) + _install_parameters(self, add_removed_parameter_aliases(self.parameters)) def process_parameters(self) -> None: """Process and transform parameters with UK-specific adjustments. @@ -284,7 +293,7 @@ def process_parameters(self) -> None: """ previous = (self.parameters, self._projected_timelines, self._raw_parameters) before_reset = getattr(self, "_parameters_before_reset", None) - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() try: # Processing changes the tree it is given, so it gets a copy: if # it fails, the system keeps the tree it had. @@ -343,12 +352,12 @@ def apply_parameter_assignments( sorted(assignments, key=lambda assignment: assignment.order) ) ] - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() statutory = self._raw_parameters is None key = _assignments_key(self.dated_assignments) if statutory else None cached = _rebuilt_parameters_cache.get(key) if key is not None else None if cached is not None: - self.parameters = cached[0].clone() + _install_parameters(self, cached[0].clone()) # Shared with the memo: read only through the copying # ``projected_timelines`` property. self._projected_timelines = cached[1] @@ -392,7 +401,7 @@ def parameters_with(self, assignments: List[ParameterAssignment]) -> ParameterNo returned is never this system's own. """ probe = copy.copy(self) - probe.parameters = self.parameters.clone() + _install_parameters(probe, self.parameters.clone()) probe.apply_parameter_assignments(assignments) return probe.parameters @@ -452,7 +461,8 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # reloaded parameters are part of it. self._raw_parameters = raw.clone() if not assignments: - self.parameters, self._projected_timelines = process_parameter_tree(raw) + parameters, self._projected_timelines = process_parameter_tree(raw) + _install_parameters(self, parameters) return # Changes to parameters processing generates are written as each is # generated; the rest go on the statutory timeline first. @@ -468,9 +478,10 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: projected = prior_law_projection(on_timeline, unreformed, statutory, generated) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) - self.parameters, self._projected_timelines = process_parameter_tree( + parameters, self._projected_timelines = process_parameter_tree( raw, unreformed, generated=generated ) + _install_parameters(self, parameters) def _apply_legacy_overrides(self) -> None: if self.legacy_overrides: @@ -478,29 +489,9 @@ def _apply_legacy_overrides(self) -> None: def __init__(self, capital_gains_projection: CapitalGainsProjection | None = None): """Initialize the UK tax-benefit system with entities and parameters.""" - self._parameters_at_instant_cache: Dict[str, Any] = {} - self.variables = {} + super().__init__(entities=[Person, BenUnit, Household]) self.capital_gains_projection = capital_gains_projection - # Create copies of entity classes to avoid modifying originals - person, benunit, household = ( - copy.copy(Person), - copy.copy(BenUnit), - copy.copy(Household), - ) - - # Set up entities - self.entities = [person, benunit, household] - self.person_entity = person - self.group_entities = [benunit, household] - self.group_entity_keys = [entity.key for entity in self.group_entities] - - # Link entities to this tax-benefit system - for entity in self.entities: - entity.set_tax_benefit_system(self) - - self.variable_module_metadata = {} - # Load all variables from the variables directory self.add_variables_from_directory(COUNTRY_DIR / "variables") @@ -528,8 +519,7 @@ def __init__(self, capital_gains_projection: CapitalGainsProjection | None = Non # the full pipeline (saves ~0.5s from convert_to_fiscal_year_parameters). # Reforms rebuild the parameters from the statutory tree through # apply_parameter_assignments, so they still get the full pipeline. - self._parameters_at_instant_cache = {} - self.parameters = _processed_parameters_cache.clone() + _install_parameters(self, _processed_parameters_cache.clone()) else: self.reset_parameters() _statutory_parameters_cache = self.parameters.clone() diff --git a/policyengine_uk/tests/test_parameter_cache_apis.py b/policyengine_uk/tests/test_parameter_cache_apis.py new file mode 100644 index 0000000000..79954c2721 --- /dev/null +++ b/policyengine_uk/tests/test_parameter_cache_apis.py @@ -0,0 +1,205 @@ +"""Dated parameter views survive the UK public cache-reset lifecycle. + +These are behavior-preservation controls for an ownership refactor. The +separate architecture gate checks that country code leaves Core cache fields +to Core; no tax-policy correction is asserted by these tests. +""" + +from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor + +import pytest +from policyengine_core.parameters import ParameterNode +from policyengine_core.reforms import Reform +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk import tax_benefit_system as tbs_module +from policyengine_uk.entities import BenUnit, Household, Person +from policyengine_uk.utils.dated_reforms import read_parameter_changes + + +def parameter_tree(): + def branch(value): + return {"nested": {"amount": {"values": {"2025-01-01": value}}}} + + return ParameterNode( + "", + data={"branch": branch(11), "baseline": {"branch": branch(23)}}, + ) + + +def small_system(): + system = CountryTaxBenefitSystem.__new__(CountryTaxBenefitSystem) + TaxBenefitSystem.__init__(system, entities=[Person, BenUnit, Household]) + system.modify_parameters(lambda _: parameter_tree()) + system.dated_assignments = [] + system.legacy_overrides = [] + system._projected_timelines = {} + system._raw_parameters = None + system._parameters_before_reset = None + system.capital_gains_projection = None + return system + + +def readings(system): + root = system.parameters + return ( + system.get_parameters_at_instant("2026-01-01").branch.nested.amount, + root("2026-01-01").branch.nested.amount, + root.branch("2026-01-01").nested.amount, + root.branch.nested("2026-01-01").amount, + root.baseline("2026-01-01").branch.nested.amount, + root.baseline.branch.nested("2026-01-01").amount, + ) + + +def test_reset_refreshes_system_and_every_descendant_without_replacing_tree(): + system = small_system() + root = system.parameters + leaf = root.branch.nested.amount + baseline = root.baseline + assert readings(system) == (11, 11, 11, 11, 23, 23) + old_view = system.get_parameters_at_instant("2026-01-01") + # A structural modifier can edit a timeline in place before asking for a + # reset. This deliberately does not invoke Parameter.update's own clearing. + leaf.values_list[0].value = 31 + root.baseline.branch.nested.amount.values_list[0].value = 47 + system.reset_parameter_caches() + assert readings(system) == (31, 31, 31, 31, 47, 47) + assert system.parameters is root + assert root.branch.nested.amount is leaf + assert root.baseline is baseline + assert system.get_parameters_at_instant("2026-01-01") is not old_view + + +def test_reset_keeps_an_actual_reforms_current_edits_and_baseline_separate(): + original = small_system() + + class Changed(Reform): + def apply(self): + self.parameters.branch.nested.amount.update(period="2026", value=31) + + reformed = Changed(original) + root = reformed.parameters + assert readings(original) == (11, 11, 11, 11, 23, 23) + assert readings(reformed) == (31, 31, 31, 31, 23, 23) + CountryTaxBenefitSystem.reset_parameter_caches(reformed) + assert reformed.parameters is root + assert readings(reformed) == (31, 31, 31, 31, 23, 23) + assert readings(original) == (11, 11, 11, 11, 23, 23) + + +def test_resets_can_repeat_inside_a_public_parameter_modifier(): + system = small_system() + root = system.parameters + assert readings(system)[0] == 11 + + def modifier(parameters): + parameters.branch.nested.amount.values_list[0].value = 37 + system.reset_parameter_caches() + system.reset_parameter_caches() + return parameters + + TaxBenefitSystem.modify_parameters(system, modifier) + assert system.parameters is root + assert readings(system) == (37, 37, 37, 37, 23, 23) + + +def test_cloned_systems_clear_only_their_own_dated_views_in_threads(): + original = small_system() + assert readings(original)[0] == 11 + + def change(amount): + clone = original.clone() + assert readings(clone)[0] == 11 + clone.parameters.branch.nested.amount.values_list[0].value = amount + clone.reset_parameter_caches() + return readings(clone), clone.parameters + + with ThreadPoolExecutor(max_workers=3) as pool: + results = list(pool.map(change, [29, 31, 37])) + for (values, root), amount in zip(results, [29, 31, 37]): + assert values == (amount, amount, amount, amount, 23, 23) + assert root is not original.parameters + assert readings(original) == (11, 11, 11, 11, 23, 23) + + +@pytest.mark.parametrize("route", ["process", "assignments"]) +def test_a_failed_rebuild_restores_tree_timelines_and_readable_views( + monkeypatch, route +): + system = small_system() + root = system.parameters + timelines = system._projected_timelines + original_values = readings(system) + + def fail(*args, **kwargs): + raise RuntimeError("deliberate processor failure") + + monkeypatch.setattr(tbs_module, "_rebuilt_parameters_cache", OrderedDict()) + monkeypatch.setattr(tbs_module, "process_parameter_tree", fail) + with pytest.raises(RuntimeError, match="deliberate processor failure"): + if route == "process": + system.process_parameters() + else: + # Use a real statutory path so assignment validation succeeds; + # the injected processor failure must exercise the rollback. + system.apply_parameter_assignments( + read_parameter_changes( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2026": 0.31}} + ) + ) + assert system.parameters is root + assert system._projected_timelines is timelines + assert system._raw_parameters is None + assert system.dated_assignments == [] + assert readings(system) == original_values + + +def test_real_cold_and_warm_uk_construction_keeps_entities_and_default_tax( + monkeypatch, +): + for name in ( + "_processed_parameters_cache", + "_statutory_parameters_cache", + "_projected_timelines_cache", + ): + monkeypatch.setattr(tbs_module, name, None) + cold = CountryTaxBenefitSystem() + warm = CountryTaxBenefitSystem() + assert cold.parameters is not warm.parameters + path = "gov.hmrc.income_tax.allowances.personal_allowance.amount" + for system in (cold, warm): + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + for entity in system.entities: + assert entity.get_variable("age") is system.get_variable("age") + situation = { + "people": {"person": {"age": {2026: 40}, "employment_income": {2026: 30_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + for system in (cold, warm): + monkeypatch.setattr( + "policyengine_uk.simulation.CountryTaxBenefitSystem", system.clone + ) + simulation = Simulation(situation=situation) + assert simulation.calculate("income_tax", 2026).tolist() == pytest.approx( + [3486] + ) + + +def test_real_reload_and_processing_keep_structural_nodes_and_baseline(): + system = CountryTaxBenefitSystem() + added = ParameterNode( + "extra_structure", data={"amount": {"values": {"2025-01-01": 17}}} + ) + system.parameters.add_child("extra_structure", added) + path = "gov.hmrc.income_tax.allowances.personal_allowance.amount" + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + system.get_parameters_at_instant("2026-01-01") + system.reset_parameters() + system.process_parameters() + assert system.parameters.extra_structure("2026-01-01").amount == 17 + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + assert system.parameters.baseline.get_child(path)("2026-01-01") == 12_570 From a1320834c9836d33b8386f7179d7a3ec932c8b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:03:08 +0100 Subject: [PATCH 036/141] Retain UK parameter structure in cache lifecycle fixtures --- policyengine_uk/tests/test_parameter_cache_apis.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_parameter_cache_apis.py b/policyengine_uk/tests/test_parameter_cache_apis.py index 79954c2721..08b6e5a822 100644 --- a/policyengine_uk/tests/test_parameter_cache_apis.py +++ b/policyengine_uk/tests/test_parameter_cache_apis.py @@ -23,10 +23,12 @@ def parameter_tree(): def branch(value): return {"nested": {"amount": {"values": {"2025-01-01": value}}}} - return ParameterNode( - "", - data={"branch": branch(11), "baseline": {"branch": branch(23)}}, - ) + # Keep the real UK subtrees, including NI scales and their compatibility + # views. Mutate only this clone, never the constructor's cached roots. + root = CountryTaxBenefitSystem().parameters.clone() + root.add_child("branch", ParameterNode("branch", data=branch(11))) + root.baseline.add_child("branch", ParameterNode("branch", data=branch(23))) + return root def small_system(): From 750de1345b99e3e2d8c598ba02e2fff016c74a78 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 16:16:05 +0200 Subject: [PATCH 037/141] Allocate the CGT annual exempt amount at the rate each schedule's top gains reach With gains stacked on taxable income, a schedule's top gains can sit at its additional rate, but the exemption was still ordered by each schedule's higher rate. Under an additional-rate reform that could set the exemption against residential gains at 24% while main gains were charged at 45% (TCGA 1992 s. 1K(5) requires the most beneficial allocation). Order the exemption per person by the rate on each schedule's top gains before the exemption, which leaves current law unchanged because the higher and additional rates are equal. Adds the mixed-schedule regression from review: 200,000 earnings, 50,000 gains of which 25,000 residential, additional rate 45%: 15,900, not 16,530. Co-Authored-By: Claude Opus 5.5 --- .../capital_gains_tax_rate_bands.yaml | 17 ++++ .../test_capital_gains_tax_components.py | 43 ++++++++++ .../capital_gains_tax/capital_gains_tax.py | 85 +++++++++++++------ 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml index fbde35bf2a..26de1e9e26 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml @@ -136,3 +136,20 @@ capital_gains: 50_000 output: capital_gains_tax: 21_150 + +- name: Additional rate reform, main and residential gains, exemption on main gains. + # Taxable income 200,000 is above the higher rate limit, so all gains are + # above the higher rate band. Main gains are at the reformed 45% additional + # rate and residential property gains at their unchanged 24%, so the 3,000 + # annual exempt amount is most beneficial against main gains (TCGA 1992 + # s. 1K(5)): 25,000 x 0.24 = 6,000 and (25,000 - 3,000) x 0.45 = 9,900. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + age: 45 + employment_income: 200_000 + capital_gains: 50_000 + capital_gains_residential_property: 25_000 + output: + capital_gains_tax: 15_900 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index e15169b740..5374c12bc7 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -336,6 +336,49 @@ def test_setting_every_schedule_taxes_every_gain_alike(self): assert reformed[schedule] == pytest.approx(41_764.5), schedule +class TestExemptionAllocation: + """The annual exempt amount goes where it saves most (TCGA 1992 s. 1K(5)). + + Gains stack on taxable income, so a schedule's top gains can be at its + additional rate. The order of the exemption is set for each person from + the rate their top gains are actually charged at. + """ + + YEAR = 2025 + ADDITIONAL_RATE_ONLY = {"gov.hmrc.cgt.additional_rate": {"2025": 0.45}} + + def test_exemption_follows_each_persons_top_rate(self): + people, benunits, households = {}, {}, {} + for i, income in enumerate((200_000, 100_000)): + people[f"person_{i}"] = { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: income}, + "capital_gains": {self.YEAR: 50_000}, + "capital_gains_residential_property": {self.YEAR: 25_000}, + } + benunits[f"benunit_{i}"] = {"members": [f"person_{i}"]} + households[f"household_{i}"] = {"members": [f"person_{i}"]} + sim = Microsimulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 200,000 is above the higher rate limit, so main + # gains are at the reformed 45% and residential gains at 24%. The + # 3,000 exemption goes against main gains: 25,000 x 0.24 + 22,000 x + # 0.45 = 15,900, not 22,000 x 0.24 + 25,000 x 0.45 = 16,530. + assert tax[0] == pytest.approx(15_900) + # Taxable income 87,430 leaves 37,710 of higher band. Main gains fill + # it first, so none reach the 45% rate and every gain is at 24%: + # 47,000 x 0.24, wherever the exemption goes. + assert tax[1] == pytest.approx(11_280) + + class TestScheduleParameters: """Dates and values of the new gov.hmrc.cgt parameters by fiscal year.""" diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index bf8ecc4ce4..37b1db91d3 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -1,3 +1,5 @@ +import numpy as np + from policyengine_uk.model_api import * from policyengine_uk.utils.capital_gains import badr_gains_before_response @@ -29,8 +31,8 @@ class capital_gains_tax(Variable): "other two in proportion with the rest of the gains. All three are " "clipped so that together they never exceed capital_gains. The annual " "exempt amount is set " - "against the schedule with the highest top rate first (TCGA 1992 " - "s. 1K(5)); the unused basic rate band goes to relief gains first, as " + "against the schedule whose top gains are charged at the highest rate " + "first (TCGA 1992 s. 1K(5)); the unused basic rate band goes to relief gains first, as " "s. 1I(4)-(6) requires, and then to the schedule with the widest gap " "between its higher and basic rates (s. 1I(7)-(9)). Gains above the " "basic rate band are split at the income tax higher rate limit only so " @@ -138,19 +140,6 @@ def component(amount): "main": main, "badr": badr, } - top_rate = {name: rates[name].higher_rate for name in rates} - top_rate["badr"] = cgt.badr.rate - - # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most - # beneficial to the taxpayer, which is against the highest top rate - # first. Relief gains carry the lowest rate and come last. - after_aea = {} - aea_left = aea - for name in sorted(amounts, key=lambda name: -top_rate[name]): - used = min_(amounts[name], aea_left) - after_aea[name] = amounts[name] - used - aea_left = aea_left - used - # Basic rate band. Relief gains take it first, TCGA 1992 s. 1I(4)-(6). # The taxpayer then allocates the rest, s. 1I(7)-(9), which saves most # on the schedule with the widest higher-minus-basic gap. Carried @@ -160,13 +149,6 @@ def component(amount): rates, key=lambda name: -(rates[name].higher_rate - rates[name].basic_rate), ) - inside_basic, above_basic = {}, {} - band_left = remaining_basic_rate_band - for name in basic_order: - inside_basic[name], above_basic[name], band_left = _take( - after_aea[name], band_left - ) - # Gains above the basic rate band are split at the higher rate limit, # stacked on top of taxable income, so reforms can charge an # additional rate; in law the two rates are equal. Relief gains are @@ -175,12 +157,59 @@ def component(amount): rates, key=lambda name: -(rates[name].additional_rate - rates[name].higher_rate), ) - inside_higher, above_higher = {}, {} - band_left = remaining_higher_rate_band - for name in higher_order: - inside_higher[name], above_higher[name], band_left = _take( - above_basic[name], band_left - ) + + def allocate_bands(schedule_amounts): + inside_basic, above_basic = {}, {} + band_left = remaining_basic_rate_band + for name in basic_order: + inside_basic[name], above_basic[name], band_left = _take( + schedule_amounts[name], band_left + ) + inside_higher, above_higher = {}, {} + band_left = remaining_higher_rate_band + for name in higher_order: + inside_higher[name], above_higher[name], band_left = _take( + above_basic[name], band_left + ) + return inside_basic, inside_higher, above_higher + + # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most + # beneficial to the taxpayer, which is against the schedule whose top + # gains are charged at the highest rate first. A schedule's top gains + # are at its additional rate when, before the exemption, any of them + # reach above the higher rate band, which happens whenever taxable + # income is already above the higher rate limit; otherwise they are + # at its higher rate. Relief gains carry their own flat rate. The + # order is set for each person, and ties keep declaration order. + _, _, above_higher_before_aea = allocate_bands(amounts) + names = list(amounts) + top_rate = np.stack( + [ + ( + np.full_like(amounts[name], cgt.badr.rate, dtype=float) + if name == "badr" + else where( + above_higher_before_aea[name] > 0, + rates[name].additional_rate, + rates[name].higher_rate, + ) + ) + for name in names + ] + ) + stacked = np.stack([amounts[name] for name in names]) + order = np.argsort(-top_rate, axis=0, kind="stable") + stacked_after_aea = np.empty_like(stacked) + aea_left = aea + for position in range(len(names)): + index = order[position : position + 1] + amount = np.take_along_axis(stacked, index, axis=0)[0] + used = min_(amount, aea_left) + np.put_along_axis(stacked_after_aea, index, (amount - used)[None], axis=0) + aea_left = aea_left - used + after_aea = {name: stacked_after_aea[i] for i, name in enumerate(names)} + + inside_basic, inside_higher, above_higher = allocate_bands(after_aea) def schedule_tax(name): schedule = rates[name] From 1cee5b318b5ed16dc2df8a65fe5345c0b29d032a Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 16:18:11 +0200 Subject: [PATCH 038/141] Bring the pension tax relief page in line with main's relief rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page said the earnings limit applies first, so someone with no earnings gets no relief, and that the annual allowance caps relief. On main, relief at source can use the £3,600 basic amount above earnings (FA 2004 s. 191(7)), the annual allowance does not limit relief, the taper tests adjusted and threshold income, the charge is sliced across the rate bands, and relieved contributions reduce adjusted net income. Mentions #2464 (SSP and SMP in relevant earnings) and #2488 (ATI uplift) as pending. Figures re-run on policyengine-uk 2.135.6. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/hmrc/pension-tax-relief.md | 98 ++++++++++++------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md index bf7c3a7f07..9db4c64e2b 100644 --- a/docs/book/programs/gov/hmrc/pension-tax-relief.md +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -5,60 +5,82 @@ pension contributions and addresses the confusion reported in [#704](https://github.com/PolicyEngine/policyengine-uk/issues/704) about whether higher-rate taxpayers receive the right amount of relief. #704 is still open (October 2026); the figures below were re-run -on policyengine-uk 2.126.12. +on policyengine-uk 2.135.6. ## What the model does `pension_contributions_relief` (defined in -[`variables/gov/hmrc/pensions/pension_contributions_relief.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py)) -reduces the person's taxable income by their pension contributions, -limited to: - -- the person's employment plus self-employment income; -- then the higher of `pension_annual_allowance` and - `gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount` (£3,600); +[`variables/gov/hmrc/pensions/pension_contributions_relief.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py), +with the limit in `utils/pensions.py`) reduces the person's taxable income +by their pension contributions, up to the annual limit for relief in +[Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190): + +- the person's relevant UK earnings, which the model takes as employment + income plus self-employment profit (a trading loss counts as zero, and + does not reduce employment income); +- raised towards `gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount` + (£3,600) where earnings are lower, but only by relief-at-source + contributions ([s. 191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191)). + The model treats `personal_pension_contributions` as relief at source and + employee contributions as a net pay arrangement, which cannot use the + basic amount; - and only for people under `gov.hmrc.pensions.pension_contributions_relief_age_limit` (75). -The law limits relief to the higher of relevant UK earnings and £3,600 -gross ([Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190)). -The model applies the earnings limit first, so someone with no earnings gets -no relief. That makes no difference to their income tax, which is zero -anyway, but it means the basic-rate top-up paid into their pension under -relief at source is not modelled. - -`pension_annual_allowance` is the standard annual allowance (£60,000 from -6 April 2023), tapered by £1 for every £2 of adjusted net income above -£260,000 down to a minimum of £10,000 (parameters under -`gov.hmrc.income_tax.allowances.annual_allowance`). The model tapers on -`adjusted_net_income`, which stands in for the statutory "adjusted -income". The money purchase annual allowance is not modelled. - -Contributions **above** the annual allowance trigger a separate charge, -`personal_pension_contributions_tax`, which taxes the excess at the -person's marginal rate and is added to income tax. The amount tested -against the allowance is `pension_contributions_for_annual_allowance`, -which counts employee, personal, employer and salary sacrifice -contributions -([Finance Act 2004 s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233)). - -One gap: the relief does not reduce `adjusted_net_income`, so contributions -do not restore personal allowance lost above £100,000, as they do in law +So someone with no earnings who pays £3,600 gross into a personal pension +gets £3,600 of relief, the basic-rate top-up paid into their pension under +relief at source, while the same payment as an employee contribution gets +none. Two open pull requests would change this section once merged: +[#2464](https://github.com/PolicyEngine/policyengine-uk/pull/2464) would add +Statutory Sick Pay and Statutory Maternity Pay to relevant earnings, and +[#2488](https://github.com/PolicyEngine/policyengine-uk/pull/2488) would apply +the same relief-at-source-only rule to the pension uplift in adjusted total +income. + +The annual allowance does not limit the relief. `pension_annual_allowance` +is the standard annual allowance (£60,000 from 6 April 2023). From 2016-17 it +is tapered only when both the statutory adjusted income +(`pension_annual_allowance_adjusted_income`, above £260,000 from 2023-24) and +threshold income (`pension_annual_allowance_threshold_income`, above the +taper level less the standard allowance, £200,000 from 2023-24) are over +their limits, by £1 for every £2 of adjusted income above the taper level, +rounded down to whole pounds, to a minimum of £10,000 +([Finance Act 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA); +parameters under `gov.hmrc.income_tax.allowances.annual_allowance`). See +`docs/engineering/pension-annual-allowance.md` for the income conventions. +The money purchase annual allowance is not modelled. + +Contributions **above** the annual allowance can still be relieved and +separately attract the annual allowance charge, +`personal_pension_contributions_tax`, which is added to income tax. It charges +the excess at the basic, higher and additional rates on the slices that fall +in each band when it is added on top of the person's income after allowances +([Finance Act 2004 s. 227](https://www.legislation.gov.uk/ukpga/2004/12/section/227)). +The amount tested against the allowance is +`pension_contributions_for_annual_allowance`, which counts member +contributions made before age 75, employer contributions and salary +sacrifice ([Finance Act 2004 s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233)). +For example, an employee on £100,000 who pays £70,000 into a personal pension +in 2025-26 gets £70,000 of relief and a £2,000 charge on the £10,000 excess, +which falls in the basic rate band. + +Relieved contributions also reduce `adjusted_net_income`, so they restore +personal allowance lost above £100,000, as in law ([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)). -An employee on £110,000 keeps a £7,570 personal allowance in 2025-26 whether -or not they contribute £10,000. +An employee on £110,000 has a £7,570 personal allowance in 2025-26, and the +full £12,570 if they pay £10,000 gross into a personal pension. -So the headline behaviour is: **within the annual allowance, pension +So the headline behaviour is: **up to the relief limit, pension contributions reduce taxable income £1-for-£1**. The income-tax saving that the model reports is the contribution × the person's marginal -rate. +rate, plus any personal allowance restored. ## Worked examples The four scenarios reported in #704 (all 2025-26, all self-employed, single person aged 40, no other income, contributions entered as `personal_pension_contributions`). The "model" column was re-run in -October 2026 on policyengine-uk 2.126.12: +October 2026 on policyengine-uk 2.135.6: | ID | Self-employed income | Pension contribution | Expected income-tax change | What the model returns | Why | |-------|---------------------|----------------------|-----------------------------|------------------------|-----| @@ -120,5 +142,5 @@ reported in #704 no longer reproduces in the model. - [Finance Act 2004 s. 188-194](https://www.legislation.gov.uk/ukpga/2004/12/section/188): primary statute for relief at source. - [Finance Act 2004 s. 227](https://www.legislation.gov.uk/ukpga/2004/12/section/227): annual allowance excess charge. - HMRC, [Personal pensions statistics](https://www.gov.uk/government/collections/personal-pensions-statistics): including the published cost of pension tax relief. -- [Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190): the earnings-or-£3,600 limit on relief. +- [Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190) and [s. 191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191): the earnings-or-£3,600 limit on relief, with the £3,600 basic amount for relief at source only. - Model variables: [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). From a6a975001f332a0017373b00d5ad416e7d19beea Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 17:00:25 +0200 Subject: [PATCH 039/141] Hold gains fixed in the exemption allocation test Main's default realisation response (#2419) moves gains under a rate reform, so switch it off as the equalisation tests do; the test checks where the exemption goes on fixed gains. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_capital_gains_tax_components.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index efd4622d9f..ff3a08a897 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -355,7 +355,12 @@ class TestExemptionAllocation: """ YEAR = 2025 - ADDITIONAL_RATE_ONLY = {"gov.hmrc.cgt.additional_rate": {"2025": 0.45}} + # The tax on gains held fixed shows where the exemption goes, so the + # realisation response is switched off. + ADDITIONAL_RATE_ONLY = { + "gov.hmrc.cgt.additional_rate": {"2025": 0.45}, + "gov.simulation.capital_gains_responses.elasticity": {"2025": 0}, + } def test_exemption_follows_each_persons_top_rate(self): people, benunits, households = {}, {}, {} From 4dfe70b50faff3e3d1c898da4450397ca1653fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:51:20 +0100 Subject: [PATCH 040/141] Keep the pension salary sacrifice cap within National Insurance --- changelog.d/2454.fixed.md | 1 + docs/engineering/salary-sacrifice-nics.md | 26 ++ .../test_salary_sacrifice_cap_reform.py | 37 ++- .../salary_sacrifice_pension_ni_employee.yaml | 7 +- .../salary_sacrifice_pension_ni_employer.yaml | 7 +- .../salary_sacrifice_returned_to_income.yaml | 25 +- ...t_salary_sacrifice_needs_pay_properties.py | 46 +--- .../tests/test_salary_sacrifice_ni_only.py | 231 ++++++++++++++++++ .../class_1/ni_class_1_income.py | 1 + .../salary_sacrifice_pension_ni_employee.py | 10 +- .../salary_sacrifice_pension_ni_employer.py | 10 +- .../salary_sacrifice_pension_ni_excess.py | 26 ++ ...ributions_via_salary_sacrifice_adjusted.py | 8 +- ...ributions_via_salary_sacrifice_from_pay.py | 6 +- .../salary_sacrifice_returned_to_income.py | 44 +--- .../employee_pension_contributions.py | 5 +- 16 files changed, 363 insertions(+), 127 deletions(-) create mode 100644 changelog.d/2454.fixed.md create mode 100644 docs/engineering/salary-sacrifice-nics.md create mode 100644 policyengine_uk/tests/test_salary_sacrifice_ni_only.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py diff --git a/changelog.d/2454.fixed.md b/changelog.d/2454.fixed.md new file mode 100644 index 0000000000..03dbc10fab --- /dev/null +++ b/changelog.d/2454.fixed.md @@ -0,0 +1 @@ +- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions. diff --git a/docs/engineering/salary-sacrifice-nics.md b/docs/engineering/salary-sacrifice-nics.md new file mode 100644 index 0000000000..427388b980 --- /dev/null +++ b/docs/engineering/salary-sacrifice-nics.md @@ -0,0 +1,26 @@ +# Salary sacrifice and the NICs cap + +`employment_income` supplies cash pay after contractual salary sacrifice, and +`pension_contributions_via_salary_sacrifice` supplies the employer contribution +separately. UKSimulation preserves this existing input convention; the model +does not deduct that sacrifice a second time from cash pay. + +The cap limits NIC exemption, rather than returning ordinary pay or creating a +member pension payment. `salary_sacrifice_pension_ni_excess` adds the excess once +to `ni_class_1_income`. Full actual sacrifice still counts once in the current +DC pension-input proxy. The capped `..._adjusted` measure is the NIC-exempt +amount. The two NI reporting measures remain approximations; actual liability +uses the Class 1 curves and age flags. + +The legacy `salary_sacrifice_returned_to_income` defaults to zero. Explicit +ordinary-return inputs retain their previous pay/member-payment semantics. +Actual reported member payments and the separate broad-base haircut assumption +are unchanged. Age-75 member accounting and annual-allowance taper work in draft +#2441 remain separate; landing either change requires retaining that work. + +The [NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) +provides the contributions-law machinery for 2029–30 onwards. The existing +2029-04-06 parameter and annual sampling are retained. This is not a complete +implementation of future payroll-interval regulations. Employer contributions +remain subject to [ITEPA 2003 s308](https://www.legislation.gov.uk/ukpga/2003/1/section/308); +member relief separately follows [FA 2004 ss188–191](https://www.legislation.gov.uk/ukpga/2004/12/section/188). diff --git a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py index f6fe96de52..12e39f3688 100644 --- a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -5,8 +5,8 @@ which takes effect from April 2029. Methodology (matching blog at https://policyengine.org/uk/research/uk-salary-sacrifice-cap): -- Employees redirect excess above £2,000 to regular pension contributions -- Regular pension contributions get income tax relief but NOT NI relief +- Excess above £2,000 is NIC-only earnings; pension contributions stay employer payments +- Ordinary income-tax treatment is unchanged; only NIC exemption is capped - Broad-base haircut: employers spread NI costs across ALL workers (~0.16% of employment income) - Revenue comes from NI on the redirected excess @@ -61,9 +61,9 @@ def test_salary_sacrifice_cap_revenue_impact(baseline_simulation, reform_simulat Test that the £2,000 salary sacrifice cap raises ~£3.3 billion. This matches the PolicyEngine blog methodology: - - Employees redirect excess to regular pension contributions - - Full excess subject to NI (broad-base haircut reduces all workers' income) - - Income tax relief preserved via regular pension contributions + - Excess is included only in NI earnings + - Broad-base haircut reduces all workers' income as a separate assumption + - Employer pension contributions retain their income-tax treatment """ baseline_gov_balance = baseline_simulation.calculate( "gov_balance", POLICY_YEAR @@ -95,7 +95,7 @@ def test_ni_increases_with_reform(baseline_simulation, reform_simulation): """ Test that total NI increases when the cap is applied. - The reform adds excess salary sacrifice back to employment income, + The reform adds excess salary sacrifice only to NIC earnings, which is then subject to NI through the normal Class 1 calculation. """ baseline_ni = baseline_simulation.calculate( @@ -127,9 +127,8 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): """ Test the income tax impact of the reform. - The excess is added to employment income and to employee pension - contributions for relief. Due to pension relief caps, some people - don't get full relief, resulting in a small positive income tax impact. + The excess is NIC-only and creates no member payment or ordinary tax. + The separate broad-base haircut can still affect ordinary tax. """ baseline_tax = baseline_simulation.calculate("income_tax", POLICY_YEAR).sum() reform_tax = reform_simulation.calculate("income_tax", POLICY_YEAR).sum() @@ -141,10 +140,8 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): print(f"Income tax change: £{tax_change / 1e9:.3f}bn") # Income tax change should be small and approximately neutral. - # The reform redirects excess salary sacrifice to employee pension - # contributions (which get income tax relief), while the total pension - # input for Annual Allowance purposes stays the same (no AA charge - # difference). Net effect is a small income tax decrease. + # NIC reclassification preserves actual pension input and ordinary pay. + # The unchanged broad-base haircut remains a separate economic effect. assert abs(tax_change) < 1e9, ( f"Income tax change should be small (<£1bn), got £{tax_change / 1e9:.3f}bn" ) @@ -153,13 +150,12 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): @pytest.mark.microsimulation def test_excess_redirected_to_pension(reform_simulation): """ - Test that full excess is redirected to employee pension contributions. + Test that full excess is included in NIC earnings. - The blog assumes employees maintain their total pension contributions - by redirecting the full excess to regular pension contributions. + Physical sacrifice remains an employer pension contribution. """ redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR + "salary_sacrifice_pension_ni_excess", POLICY_YEAR ).sum() # Should be significant (blog says £13.8bn excess - updated to current model) @@ -217,10 +213,9 @@ def test_affected_population(reform_simulation): @pytest.mark.microsimulation def test_full_excess_redirected(reform_simulation): """ - Test that the full excess is redirected (no targeted haircut). + Test that the full excess is included in NIC earnings (no targeted haircut). - The broad-base haircut reduces ALL workers' employment income, - but the full excess above cap is redirected to regular pension contributions. + The separate broad-base haircut still reduces ALL workers' employment income. """ # Get weighted totals using map_to for proper aggregation. Only people # with pay have a salary sacrifice, so the excess is measured on that. @@ -231,7 +226,7 @@ def test_full_excess_redirected(reform_simulation): ) weights = reform_simulation.calculate("person_weight", POLICY_YEAR) redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR, map_to="person" + "salary_sacrifice_pension_ni_excess", POLICY_YEAR, map_to="person" ) cap = 2000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml index 5b36e53d92..6c065272de 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml @@ -91,7 +91,7 @@ # £100 excess * 8% = £8 salary_sacrifice_pension_ni_employee: 100 * 0.08 -- name: Employee NI on full redirected excess +- name: Employee NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,8 +103,9 @@ output: # £1,000 excess * 8% = £80 salary_sacrifice_pension_ni_employee: (3_000 - 2_000) * 0.08 - # Full excess is redirected (no targeted haircut) - salary_sacrifice_returned_to_income: 1_000 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 - name: Broad-base haircut reduces all workers employment income period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml index 4eb0fdf97c..669a26ce24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml @@ -91,7 +91,7 @@ # £3,000 excess * 15% = £450 salary_sacrifice_pension_ni_employer: (5_000 - 2_000) * 0.15 -- name: Employer NI on full redirected excess +- name: Employer NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,5 +103,6 @@ output: # £1,000 excess * 15% = £150 salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 - # Full excess is redirected (no targeted haircut) - salary_sacrifice_returned_to_income: 1_000 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml index 2f3573564d..66b488d30e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml @@ -1,6 +1,4 @@ -# From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -# employment income. Only people with pay have a salary sacrifice, so the cap -# never gives pay to a person who has none. +# The cap creates NIC earnings only; ordinary pay/member payments stay unchanged. - name: No pay and a salary sacrifice input gives no employment income, 2029 period: 2029 @@ -34,7 +32,7 @@ salary_sacrifice_returned_to_income: 0 employment_income: 0 -- name: Pay above the sacrifice returns the excess over the cap, 2029 +- name: Pay above the sacrifice has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -43,11 +41,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 5,000 - 2,000 cap - salary_sacrifice_returned_to_income: 3_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 3_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 43_000 + employment_income: 40_000 -- name: A whole-bonus sacrifice above the remaining salary returns its full excess, 2030 +- name: A whole-bonus sacrifice above the remaining salary has its full NIC excess, 2030 period: 2030 absolute_error_margin: 0.01 input: @@ -56,11 +55,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 50,000 - 2,000 cap - salary_sacrifice_returned_to_income: 48_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 48_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 88_000 + employment_income: 40_000 -- name: Low pay with a large sacrifice still returns the excess over the cap, 2029 +- name: Low pay with a large sacrifice still has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -68,9 +68,10 @@ pension_contributions_via_salary_sacrifice: 30_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_returned_to_income: 28_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 28_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 29_500 + employment_income: 1_500 - name: Nothing returns before the cap starts, 2028 period: 2028 diff --git a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py index 043e485ff6..cc51cdb718 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py @@ -1,31 +1,9 @@ -"""Property-based tests: only people with pay have a salary sacrifice. - -From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -employment income (salary_sacrifice_returned_to_income). Only people with pay -have a salary sacrifice (pension_contributions_via_salary_sacrifice_from_pay), -so an input sacrifice with no pay behind it cannot create pay. A sacrifice -above the pay that remains (a whole bonus, say) is kept in full. - -Invariants, for any generated pay and salary sacrifice, in a year before the -cap (2028) and two years under it (2029, 2030): - -1. Definition: the sacrifice from pay is max(sacrifice, 0) with pay, else 0. -2. Conservation: the capped sacrifice plus the amount returned to income - equals the sacrifice from pay. -3. No pay, no gain: a person without pay has no employment income, nothing - returned and no National Insurance charge on a returned amount, whatever - their sacrifice input. -4. Nothing is returned before the cap starts. -5. Accounting: employment income equals pay plus the amount returned plus - the broad-base haircut. -6. Unchanged with pay: for a person with pay, the amount returned is the - sacrifice above the cap, as before this condition. -7. Monotonicity: the amount returned never falls when the sacrifice or the - pay rises. - -The simulations go through policyengine_uk.Simulation, which moves an -employment_income input to employment_income_before_lsr as it does for a -dataset or a household calculation. +"""Only people with pay have salary sacrifice and its NIC-only excess. + +The capped exempt amount plus NIC-only excess conserves the full employer +contribution. Ordinary employment income remains cash pay plus the existing +broad-base haircut. Generated pay, no-pay and whole-bonus inputs retain the +existing UKSimulation input convention and branch/reset compatibility. """ import numpy as np @@ -100,7 +78,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): adjusted = _values( simulation, "pension_contributions_via_salary_sacrifice_adjusted", year ) - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) employment_income = _values(simulation, "employment_income", year) haircut = _values(simulation, "salary_sacrifice_broad_base_haircut", year) cap = _cap(simulation, year) @@ -124,9 +102,9 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): # 4. Nothing is returned before the cap starts. if np.isinf(cap): assert np.all(returned == 0) - # 5. Accounting. + # 5. Ordinary cash-pay accounting; the excess is NIC-only. np.testing.assert_allclose( - employment_income, pays + returned + haircut, rtol=1e-6, atol=1e-2 + employment_income, pays + haircut, rtol=1e-6, atol=1e-2 ) # 6. Unchanged with pay. paid = pays > 0 @@ -141,7 +119,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): @PROPERTY_SETTINGS @given(people) -def test_returned_income_never_falls_with_sacrifice_or_pay(rows): +def test_ni_excess_never_falls_with_sacrifice_or_pay(rows): n = len(rows) pays = np.array([row[0] for row in rows]) sacrifices = np.array([row[1] for row in rows]) @@ -154,7 +132,7 @@ def test_returned_income_never_falls_with_sacrifice_or_pay(rows): np.concatenate([sacrifices, sacrifices + more_sacrifice, sacrifices]), ) for year in YEARS: - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) base, larger_sacrifice, larger_pay = ( returned[:n], returned[n : 2 * n], @@ -176,4 +154,4 @@ def test_pay_set_after_construction_keeps_the_sacrifice(): assert _values( simulation, "pension_contributions_via_salary_sacrifice_from_pay", 2030 ) == [6_000] - assert _values(simulation, "salary_sacrifice_returned_to_income", 2030) == [4_000] + assert _values(simulation, "salary_sacrifice_pension_ni_excess", 2030) == [4_000] diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py new file mode 100644 index 0000000000..8debaccabf --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py @@ -0,0 +1,231 @@ +"""The pension sacrifice cap changes NIC earnings, not pay or member payments. + +NI Act 2026 ss1–2 and ITEPA 2003 s308 preserve the employer contribution. +FA 2004 ss188/190/191 make a synthetic member-payment offset unsafe at 75 +and where relief is limited. Haircut zero isolates classification from the +separate, unchanged employer-incidence assumption. Cash pay is already net +of contractual sacrifice, as required by the existing UKSimulation adapter. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEARS = (2028, 2029, 2030) +CAP = "gov.hmrc.national_insurance.salary_sacrifice_pension_cap" +HAIRCUT = "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate" +# age, cash wages, employer sacrifice, actual net-pay member payment, gross RAS +CASES = [ + (80, 160_000, 30_000, 0, 0), + (80, 100_000, 30_000, 0, 0), + (75, 100_000, 30_000, 1_000, 0), + (74, 1_500, 30_000, 0, 3_600), + (40, 49_000, 5_000, 0, 0), + (40, 0, 30_000, 0, 0), + (40, 40_000, 2_000, 0, 0), + (40, 40_000, 1_500, 0, 0), + (40, 40_000, 5_000, 1_000, 2_000), +] + + +def simulations(): + people = {} + for i, (age, wages, sacrifice, member, ras) in enumerate(CASES): + people[f"p{i}"] = { + name: {str(year): value for year in YEARS} + for name, value in { + "age": age, + "employment_income": wages, + "pension_contributions_via_salary_sacrifice": sacrifice, + "employee_pension_contributions": member, + "personal_pension_contributions": ras, + "employer_pension_contributions": 2_000, + }.items() + } + situation = { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + isolated = {HAIRCUT: {"2028-01-01.2030-12-31": 0}} + return ( + Simulation(situation=situation, reform=isolated), + Simulation( + situation=situation, + reform={**isolated, CAP: {"2029-01-01.2030-12-31": float("inf")}}, + ), + ) + + +@pytest.fixture(scope="module") +def pair(): + return simulations() + + +def values(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +def ordinary_tax(sim, year): + return values(sim, "income_tax", year) - values( + sim, "personal_pension_contributions_tax", year + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_nic_cap_preserves_ordinary_tax_pay_member_payments_and_allowances(pair, year): + capped, unlimited = pair + # These reach existing final variables, so RED must be a numerical failure, + # never a missing new variable or a forced final/intermediate value. + np.testing.assert_allclose( + ordinary_tax(capped, year), ordinary_tax(unlimited, year), atol=0.02, rtol=1e-6 + ) + for name in ( + "employment_income", + "employee_pension_contributions", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_relief", + ): + np.testing.assert_allclose( + values(capped, name, year), + values(unlimited, name, year), + atol=0.02, + rtol=1e-6, + err_msg=name, + ) + np.testing.assert_allclose( + values(capped, "employment_income", year), [c[1] for c in CASES] + ) + np.testing.assert_allclose( + values(capped, "employee_pension_contributions", year), [c[3] for c in CASES] + ) + assert values(capped, "pension_contributions_relief", year)[3] == 3_600 + + +def test_no_cap_before_2029_preserves_real_inputs(pair): + capped, unlimited = pair + np.testing.assert_allclose( + ordinary_tax(capped, 2028), ordinary_tax(unlimited, 2028) + ) + np.testing.assert_allclose( + values(capped, "employment_income", 2028), [c[1] for c in CASES] + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_actual_class_1_curves_charge_the_excess_once(pair, year): + capped, unlimited = pair + excess = np.array([28_000, 28_000, 28_000, 28_000, 3_000, 0, 0, 0, 3_000]) + wages = np.array([c[1] for c in CASES]) + np.testing.assert_allclose( + values(capped, "ni_class_1_income", year), wages + excess + ) + np.testing.assert_allclose(values(unlimited, "ni_class_1_income", year), wages) + p = capped.tax_benefit_system.parameters(year).gov.hmrc.national_insurance.class_1 + + # Independent piecewise annual curves, not the approximate reporting helper. + def employee(earnings): + pt, uel = ( + p.thresholds.primary_threshold * 52, + p.thresholds.upper_earnings_limit * 52, + ) + liability = ( + np.minimum(np.maximum(earnings - pt, 0), uel - pt) * p.rates.employee.main + + np.maximum(earnings - uel, 0) * p.rates.employee.additional + ) + return np.where( + np.array([False, False, False, False, True, True, True, True, True]), + liability, + 0, + ) + + expected_employee = employee(wages + excess) - employee(wages) + actual_employee = values(capped, "national_insurance", year) - values( + unlimited, "national_insurance", year + ) + np.testing.assert_allclose(actual_employee, expected_employee, atol=0.02, rtol=1e-6) + st = p.thresholds.secondary_threshold * 52 + expected_employer = ( + np.maximum(wages + excess - st, 0) - np.maximum(wages - st, 0) + ) * p.rates.employer + np.testing.assert_allclose( + values(capped, "ni_class_1_employer", year) + - values(unlimited, "ni_class_1_employer", year), + expected_employer, + atol=0.02, + rtol=1e-6, + ) + # £49k crosses the UEL: each slice, rather than all £3k at one rate. + assert 0 < expected_employee[4] < 3_000 * p.rates.employee.main + + +def test_dc_inputs_and_final_household_effect_conserve_real_payments(pair): + capped, unlimited = pair + for year in (2029, 2030): + expected = [ + member + ras + 2_000 + (sacrifice if wages > 0 else 0) + for _, wages, sacrifice, member, ras in CASES + ] + np.testing.assert_allclose( + values(capped, "pension_contributions_for_annual_allowance", year), expected + ) + np.testing.assert_allclose( + values(unlimited, "pension_contributions_for_annual_allowance", year), + expected, + ) + # At £49k there is no AA excess, no benefit entitlement and no relief: + # the cap's only final household effect is actual employee NICs. + i = 4 + ni_delta = ( + values(capped, "national_insurance", year)[i] + - values(unlimited, "national_insurance", year)[i] + ) + for name, sign in ( + ("household_tax", 1), + ("household_net_income", -1), + ("hbai_household_net_income", -1), + ): + actual = values(capped, name, year)[i] - values(unlimited, name, year)[i] + assert actual == pytest.approx(sign * ni_delta, abs=0.02), name + + +def test_explicit_ordinary_return_input_remains_supported(): + sim = Simulation( + situation={ + "people": { + "p": { + "age": {"2026": 40}, + "employment_income": {"2026": 40_000}, + "employee_pension_contributions": {"2026": 1_000}, + "salary_sacrifice_returned_to_income": {"2026": 500}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + ) + assert values(sim, "employment_income", 2026)[0] == 40_500 + assert values(sim, "employee_pension_contributions", 2026)[0] == 1_500 + + +def test_ni_reporting_readers_retain_their_existing_approximation(pair): + capped, _ = pair + for year in (2029, 2030): + # These reporting measures deliberately retain the known #2363 + # approximation, including no pension-age gate. Actual NICs above + # are independently checked through national_insurance instead. + p = capped.tax_benefit_system.parameters( + year + ).gov.hmrc.national_insurance.class_1 + assert values(capped, "salary_sacrifice_pension_ni_employee", year)[ + 0 + ] == pytest.approx(28_000 * p.rates.employee.additional, abs=0.02) + assert values(capped, "salary_sacrifice_pension_ni_employer", year)[ + 0 + ] == pytest.approx(28_000 * p.rates.employer, abs=0.02) + assert np.all(values(capped, "salary_sacrifice_returned_to_income", year) == 0) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py index a54fc2da39..184c12e3a9 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py @@ -10,6 +10,7 @@ class ni_class_1_income(Variable): unit = GBP adds = [ "employment_income", + "salary_sacrifice_pension_ni_excess", "statutory_sick_pay", "statutory_maternity_pay", "statutory_paternity_pay", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index ba71d85791..d9a8cce55d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employee(Variable): documentation = ( "Additional employee National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to NI (but gets income tax relief)." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting approximation is separate " + "from the actual Class 1 contribution curves and liability flags." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employee(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 parameters ni_params = parameters(period).gov.hmrc.national_insurance.class_1 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..c20736b37c 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employer(Variable): documentation = ( "Additional employer National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to employer NI." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting measure is separate from " + "the actual Class 1 contribution curve and liability flags." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employer(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to employer NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 employer rate parameter ni_params = parameters(period).gov.hmrc.national_insurance.class_1 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py new file mode 100644 index 0000000000..452ebf4841 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class salary_sacrifice_pension_ni_excess(Variable): + label = "Salary sacrifice pension contributions subject to NICs" + documentation = ( + "Employer pension contributions through salary sacrifice above the NICs " + "exemption cap. This is added only to Class 1 earnings: it is neither " + "cash employment income nor an actual member pension contribution. " + "The existing annual model samples the 2029-04-06 cap; payroll interval " + "rules and individual transaction dates are not modelled." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2026/15/section/1/enacted" + + def formula(person, period, parameters): + sacrifice = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + return max_(sacrifice - cap, 0) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py index 3334cfa70c..09184f00c9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py @@ -4,10 +4,10 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): label = "Adjusted salary sacrifice pension contributions (capped)" documentation = ( - "The actual amount of salary sacrifice pension contributions after " + "The NIC-exempt amount of salary sacrifice pension contributions after " "counting them only for people with pay and applying the cap. " - "Contributions above the cap are redirected to " - "regular employee pension contributions and subject to NI." + "Contributions above the cap remain employer pension contributions, " + "but are subject to NICs through salary_sacrifice_pension_ni_excess." ) entity = Person definition_period = YEAR @@ -28,5 +28,5 @@ def formula(person, period, parameters): return intended_ss # Cap the salary sacrifice at the limit - # Excess is redirected to regular pension contributions + # The excess is separately included in NIC earnings only. return min_(intended_ss, cap) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py index 62cb1a357e..fd8ec12eda 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py @@ -11,9 +11,9 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): "from an employment_income input) has nothing to sacrifice. The " "sacrifice is not limited to the pay that remains: an employee may " "sacrifice, for example, a whole bonus, as long as their cash pay stays " - "above the National Minimum Wage. The cap on salary sacrifice returns " - "the excess over the cap to employment income, so without this " - "condition an input sacrifice with no pay behind it would create pay." + "above the National Minimum Wage. The cap includes excess sacrifice " + "only in NIC earnings. Without this pay condition a sacrifice " + "input with no pay behind it would create a NIC earnings base." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py index 72c7ce0335..13a68de660 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py @@ -2,44 +2,18 @@ class salary_sacrifice_returned_to_income(Variable): - label = "Amount of salary sacrifice redirected to employee pension contributions" + label = "Salary sacrifice actually returned to ordinary income" documentation = ( - "The amount of excess salary sacrifice (above the cap) that is redirected " - "to regular employee pension contributions. This maintains total pension savings " - "while subjecting the excess to National Insurance (but not income tax, since " - "regular pension contributions receive income tax relief). " - "\n\n" - "The full excess is redirected - the employer cost increase is handled via " - "the broad-base haircut (salary_sacrifice_broad_base_haircut) which reduces " - "ALL workers' employment income by ~0.16%, not just affected workers." - "\n\n" - "Only people with pay have a salary sacrifice " - "(pension_contributions_via_salary_sacrifice_from_pay), so nothing is " - "returned to a person without pay." + "Legacy ordinary-pay/member-payment input, defaulting to zero. The " + "pension salary-sacrifice cap changes only NIC earnings, through " + "salary_sacrifice_pension_ni_excess; it does not return cash pay or " + "create a member contribution. Explicit inputs remain supported for " + "callers representing an actual return to ordinary pay and member " + "payments under the existing input convention." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" - - def formula(person, period, parameters): - intended_ss = person( - "pension_contributions_via_salary_sacrifice_from_pay", period - ) - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, no excess to redirect - if np.isinf(cap): - return 0 - - # Calculate excess above cap - full excess is redirected to regular - # employee pension contributions (no targeted haircut on the individual) - excess = max_(intended_ss - cap, 0) - - # Full excess is redirected to employment income (and then to employee - # pension contributions for income tax relief). The employer NI cost - # increase is spread across ALL workers via salary_sacrifice_broad_base_haircut. - return excess + default_value = 0 + reference = "https://www.legislation.gov.uk/ukpga/2003/1/section/308" diff --git a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py index b0da36b0bf..f7aba9e1ba 100644 --- a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py +++ b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py @@ -4,8 +4,9 @@ class employee_pension_contributions(Variable): label = "employee pension contributions" documentation = ( - "Total employee pension contributions including reported contributions " - "and any excess salary sacrifice redirected to regular pension contributions." + "Actual reported employee pension contributions, plus any explicitly " + "supplied legacy return to ordinary pay/member payments. The " + "NIC-only salary-sacrifice excess is not a member contribution." ) entity = Person definition_period = YEAR From ba1508c10593d87a0d71624d9ebecc68dd363e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:44:30 +0100 Subject: [PATCH 041/141] Preserve actual pension payments when integrating NI-only sacrifice cap --- changelog.d/2454.fixed.md | 2 +- docs/engineering/pension-annual-allowance.md | 46 ++--- docs/engineering/salary-sacrifice-nics.md | 9 +- .../pensions/pension_annual_allowance.yaml | 16 +- .../tests/test_salary_sacrifice_ni_only.py | 161 +++++++++++++++++- policyengine_uk/utils/pensions.py | 42 ++--- ...ension_annual_allowance_adjusted_income.py | 7 +- ...nsion_annual_allowance_threshold_income.py | 31 +--- ...sion_contributions_for_annual_allowance.py | 10 +- 9 files changed, 227 insertions(+), 97 deletions(-) diff --git a/changelog.d/2454.fixed.md b/changelog.d/2454.fixed.md index 03dbc10fab..6a578cabd3 100644 --- a/changelog.d/2454.fixed.md +++ b/changelog.d/2454.fixed.md @@ -1 +1 @@ -- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions. +- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, and retained actual supplied ordinary returns in the annual-allowance calculations. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 906d7d60d9..ac881ae9de 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -27,9 +27,11 @@ is deducted separately through `allowances`. Employee contributions represent net pay and personal contributions represent gross relief at source (RAS). Both annual-allowance income tests use [`annual_allowance_net_income`](../../policyengine_uk/utils/pensions.py). -This shared helper reconstructs the model's net-income convention from actual -income, before separately modeled pension relief, excluding the NICs-only -salary-sacrifice return. Accordingly: +This shared helper retains the model's net-income convention before separately +modeled pension relief and applies net-pay/trading-loss ordering to actual +income and member payments. The NICs-only excess never enters these amounts; +a positive supplied legacy ordinary return is actual pay/member payment. +Accordingly: - `pension_annual_allowance_adjusted_income` adds the total pension input amount to that reconstructed income, then subtracts the individual's actual @@ -69,20 +71,19 @@ prevents Step 2 relief from exceeding the remaining income. This limit applies in every model year, including before the NICs cap begins in 2029. It can therefore change pre-2029 allowances for people whose trading-loss relief is limited by income and who make relievable net-pay contributions. -Removing synthetic pay after deducting the -original relief would retain relief calculated on income the person did not -receive. Threshold income also recomputes [FA 2004 ss. 188–191 pension +The helper replaces the original loss with the loss allowed after relievable +net pay. Threshold income deducts [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on -actual contributions and earnings. It does not remove returned pay or -reconstruct trading-loss relief: the supplied ANI contains neither synthetic -pay nor relief recalculated by the NICs reclassification. The annual-allowance -base is therefore independent of the NICs cap on this path too. +actual contributions and earnings. It does not remove an actual supplied +ordinary return or reconstruct trading-loss relief: the supplied ANI already +determines the income after those losses. The annual-allowance base is +independent of the NICs cap on this path too. -Other modeled deductions do not depend on the returned pay: brought-forward +Other modeled deductions retain their existing treatment: brought-forward trade losses, capital allowances and the trading allowance depend on trading profits. Property finance-cost relief is a downstream tax reducer. Gift Aid's adjusted-net-income deduction and rate-limit extension do not reduce either @@ -124,24 +125,23 @@ this correction does not reconstruct DB rights, overseas adjustments, unused allowance histories or anti-avoidance arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) -changes NICs treatment without changing s. 228ZA. From 2029, the model moves -sacrifice above the NICs exemption cap into -employee contributions and employment income. The AA calculations remove -that synthetic return from both pay and member contributions and recompute -income-dependent relief as described above. Pension relief preserves the +changes NICs treatment without changing s. 228ZA. From 2029, the model includes +sacrifice above the NICs exemption cap only in Class 1 earnings through +`salary_sacrifice_pension_ni_excess`. It does not create ordinary pay or member +contributions. The legacy `salary_sacrifice_returned_to_income` defaults to +zero; a positive explicit input represents actual ordinary pay/member payment +and is retained in the AA calculations. Pension relief preserves the basic RAS amount even when cash earnings are below £3,600 and gives no member relief from age 75. The DC input keeps the full employer sacrifice at every age. The threshold-income add-back uses the full -sacrifice before this reclassification. The separate employer-cost wage +sacrifice before the NICs cap. The separate employer-cost wage response can still change income; it is held fixed when testing the cap's statutory effect. -Cap invariance covers the two taper incomes, pension input and allowance. -Charge comparisons additionally hold ordinary income-tax inputs fixed: the -separate ANI and personal-allowance interaction can change the marginal tax -charged on the same excess. Ordinary income tax retains its existing -reclassification behavior; [#2454](https://github.com/PolicyEngine/policyengine-uk/issues/2454) -tracks that separate defect. +With the separate wage response held fixed, the cap does not change ordinary +pay, member payments, ANI or the personal allowance. Cap-invariance checks +cover those paths alongside the two taper incomes, pension input, allowance +and charge. Actual NI liability changes through the existing Class 1 curves. The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation diff --git a/docs/engineering/salary-sacrifice-nics.md b/docs/engineering/salary-sacrifice-nics.md index 427388b980..86da793f53 100644 --- a/docs/engineering/salary-sacrifice-nics.md +++ b/docs/engineering/salary-sacrifice-nics.md @@ -15,8 +15,13 @@ uses the Class 1 curves and age flags. The legacy `salary_sacrifice_returned_to_income` defaults to zero. Explicit ordinary-return inputs retain their previous pay/member-payment semantics. Actual reported member payments and the separate broad-base haircut assumption -are unchanged. Age-75 member accounting and annual-allowance taper work in draft -#2441 remain separate; landing either change requires retaining that work. +are unchanged. The merged #2441 age-75 member accounting and annual-allowance +taper rules are retained: member payments before age 75, employer payments and +full sacrifice enter the default DC input, while member payments from age 75 +are excluded. A positive supplied ordinary return is actual pay and a member +payment in these readers too; it is not removed as an unreceived NICs return. +The AA helper still applies the net-pay/trading-loss ordering, RAS earnings and +age limits, supplied-ANI restoration and statutory pension-input overrides. The [NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) provides the contributions-law machinery for 2029–30 onwards. The existing diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml index 1630f917da..0ed741ac8e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -241,11 +241,11 @@ pension_annual_allowance_threshold_income: 220_000 pension_annual_allowance: 40_000 -# The NICs cap redirects 28,000 to employee contributions and the broad-base -# haircut reduces pay by 280. Threshold income still adds back all 30,000: -# 202,720 - 28,000 + 30,000 = 204,720. Pension input remains 130,000, -# adjusted income is 202,720 + 130,000 - 28,000 = 304,720, and the charge -# is (130,000 - 37,640) * 45% = 41,562. +# The unchanged broad-base haircut reduces actual pay by 280. The 28,000 +# NICs excess creates neither ordinary pay nor a member payment. Threshold +# income still adds back all 30,000: 174,720 + 30,000 = 204,720. Pension +# input remains 130,000, adjusted income is 174,720 + 130,000 = 304,720, +# and the charge is (130,000 - 37,640) * 45% = 41,562. - name: The 2029 NICs cap does not limit the salary sacrifice threshold income addback period: 2029 input: @@ -254,8 +254,10 @@ pension_contributions_via_salary_sacrifice: 30_000 employer_pension_contributions: 100_000 output: - employment_income: 202_720 - pension_contributions_relief: 28_000 + employment_income: 174_720 + pension_contributions_relief: 0 + salary_sacrifice_pension_ni_excess: 28_000 + ni_class_1_income: 202_720 pension_contributions_for_annual_allowance: 130_000 pension_annual_allowance_threshold_income: 204_720 pension_annual_allowance_adjusted_income: 304_720 diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py index 8debaccabf..b00849226e 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py @@ -168,8 +168,8 @@ def test_dc_inputs_and_final_household_effect_conserve_real_payments(pair): capped, unlimited = pair for year in (2029, 2030): expected = [ - member + ras + 2_000 + (sacrifice if wages > 0 else 0) - for _, wages, sacrifice, member, ras in CASES + (member + ras if age < 75 else 0) + 2_000 + (sacrifice if wages > 0 else 0) + for age, wages, sacrifice, member, ras in CASES ] np.testing.assert_allclose( values(capped, "pension_contributions_for_annual_allowance", year), expected @@ -229,3 +229,160 @@ def test_ni_reporting_readers_retain_their_existing_approximation(pair): 0 ] == pytest.approx(28_000 * p.rates.employer, abs=0.02) assert np.all(values(capped, "salary_sacrifice_returned_to_income", year) == 0) + + +# A supplied legacy return is actual pay plus an actual member payment. It +# must agree with entering those amounts directly, including in the merged +# annual-allowance readers. These are distinct from a computed NICs excess. +ACTUAL_RETURN_CASES = [ + dict(age=40, pay=40_000, member=1_000, returned=500), + dict(age=75, pay=40_000, member=1_000, returned=500), + dict(age=74, pay=1_500, member=0, returned=500, ras=3_600), + dict(age=40, pay=40_000, member=1_000, returned=500, ani=39_000), + dict(age=75, pay=40_000, member=1_000, returned=500, ani=40_500), + dict(age=40, pay=200_000, member=5_000, returned=5_000, loss=400_000), + dict(age=40, pay=40_000, member=1_000, returned=500, dc=90_000), + dict( + age=40, + pay=200_000, + member=5_000, + returned=5_000, + employer=30_000, + sacrifice=30_000, + ), + dict(age=74, pay=40_000, member=1_000, returned=0, loss=100_000), +] +ACTUAL_RETURN_OUTPUTS = ( + "employment_income", + "employee_pension_contributions", + "pension_contributions", + "pension_contributions_relief", + "net_income", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + "income_tax", + "ni_class_1_income", + "national_insurance", + "household_tax", + "household_net_income", + "hbai_household_net_income", +) + + +def actual_return_simulations(cases): + def situation(explicit_return): + people = {} + for i, case in enumerate(cases): + returned = case["returned"] + inputs = { + "age": case["age"], + "employment_income": case["pay"] + (0 if explicit_return else returned), + "employee_pension_contributions": case["member"] + + (0 if explicit_return else returned), + "personal_pension_contributions": case.get("ras", 0), + "employer_pension_contributions": case.get("employer", 0), + "pension_contributions_via_salary_sacrifice": case.get("sacrifice", 0), + } + if explicit_return: + inputs["salary_sacrifice_returned_to_income"] = returned + for key, variable in ( + ("ani", "adjusted_net_income"), + ("loss", "trading_loss"), + ("dc", "pension_contributions_for_annual_allowance"), + ): + if key in case: + inputs[variable] = case[key] + people[f"p{i}"] = { + name: {"2026": amount} for name, amount in inputs.items() + } + return { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + + return tuple( + Simulation( + situation=situation(explicit_return), + reform={HAIRCUT: {"2026-01-01.2026-12-31": 0}}, + ) + for explicit_return in (True, False) + ) + + +@pytest.fixture(scope="module") +def actual_return_pair(): + return actual_return_groups() + + +def actual_return_groups(): + # A supplied variable is stored for the whole Person array. Keep natural + # cases separate from ANI and DC overrides so absent rows are not filled + # with zero inputs that mask the very formulas under test. + return [ + (indices, actual_return_simulations([ACTUAL_RETURN_CASES[i] for i in indices])) + for indices in ([0, 1, 2, 5, 7, 8], [3, 4], [6]) + ] + + +def actual_return_values(groups, member, variable): + result = np.empty(len(ACTUAL_RETURN_CASES)) + for indices, pair in groups: + result[indices] = values(pair[member], variable, 2026) + return result + + +@pytest.mark.parametrize("variable", ACTUAL_RETURN_OUTPUTS) +def test_explicit_actual_return_matches_actual_pay_and_member_inputs( + actual_return_pair, variable +): + np.testing.assert_allclose( + actual_return_values(actual_return_pair, 0, variable), + actual_return_values(actual_return_pair, 1, variable), + atol=0.02, + rtol=1e-6, + err_msg=variable, + ) + + +def test_explicit_actual_return_has_independent_aa_and_final_charge_oracles( + actual_return_pair, +): + # 2026 actual pay205000/member10000, employer30000 and sacrifice30000: + # DC70000; adjusted265000; threshold225000; taper(265000-260000)/2; + # allowance57500; excess12500, charged at the unchanged45% marginal rate. + expected = { + "employment_income": 205_000, + "employee_pension_contributions": 10_000, + "pension_contributions_for_annual_allowance": 70_000, + "pension_annual_allowance_adjusted_income": 265_000, + "pension_annual_allowance_threshold_income": 225_000, + "pension_annual_allowance": 57_500, + "personal_pension_contributions_tax": 5_625, + } + for member in (0, 1): + for variable, amount in expected.items(): + assert actual_return_values(actual_return_pair, member, variable)[ + 7 + ] == pytest.approx(amount, abs=0.02), variable + assert actual_return_values(actual_return_pair, member, "income_tax")[7] > 5_625 + + +def test_explicit_actual_return_is_query_order_independent(): + groups = actual_return_groups() + actual = { + name: actual_return_values(groups, 0, name) + for name in reversed(ACTUAL_RETURN_OUTPUTS) + } + for name in ACTUAL_RETURN_OUTPUTS: + np.testing.assert_allclose( + actual[name], actual_return_values(groups, 1, name), atol=0.02, rtol=1e-6 + ) diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 4c9b81729c..0d052156e3 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -23,8 +23,7 @@ def pension_contributions_relief_amount( Only RAS can use the basic-amount increase above earnings (s. 191(7)). The annual allowance does not cap relief, and payments from age 75 are - unrelievable. Keeping this calculation shared lets the AA income tests - exclude the NICs-only salary-sacrifice reclassification exactly. + unrelievable. The AA income tests use the same actual-payment convention. """ basic_amount_increase = np.minimum( np.maximum( @@ -40,24 +39,23 @@ def pension_contributions_relief_amount( def annual_allowance_net_income(person, period, parameters): - """Model net income rebuilt without NICs-only returned salary sacrifice. + """Model net income with the annual-allowance net-pay/loss ordering. Like net_income, this is before the separately modelled pension relief. - ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction, not - subtracting returned pay after an income-dependent deduction was taken. - Ordinary income tax keeps its existing calculation (issue #2454). + ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction after + relievable net pay. A supplied legacy return is actual pay/member payment; + the computed NICs-only excess never enters income or contributions. """ from policyengine_core.model_api import add - returned = person("salary_sacrifice_returned_to_income", period) hmrc = parameters(period).gov.hmrc - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned + actual_member_contributions = person("pension_contributions", period) + actual_earnings = relevant_uk_earnings(person, period) supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. # Restore only Gift Aid and pension relief on actual earnings/payments; - # neither returned pay nor a replacement loss deduction belongs here. + # A replacement loss deduction does not belong on this input path. return ( supplied_ani + person("gift_aid_grossed_up", period) @@ -71,21 +69,16 @@ def annual_allowance_net_income(person, period, parameters): ) net_income = person("net_income", period) - actual_net_pay = person("employee_pension_contributions", period) - returned + actual_net_pay = person("employee_pension_contributions", period) relievable_net_pay = pension_contributions_relief_amount( actual_net_pay, actual_earnings, 0, person("age", period), hmrc ) - if not np.any(returned) and not np.any( - (relievable_net_pay > 0) & (person("trading_loss", period) > 0) - ): + if not np.any((relievable_net_pay > 0) & (person("trading_loss", period) > 0)): return net_income - taxable_pay = person("taxable_employment_income", period) - actual_taxable_pay = np.maximum(0, taxable_pay - returned) - removed_taxable_pay = taxable_pay - actual_taxable_pay + actual_taxable_pay = person("taxable_employment_income", period) income_before_loss = np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - removed_taxable_pay, + 0, person("net_income_before_trade_loss_relief", period) ) # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member # contributions, retaining its treatment of brought-forward trade losses @@ -127,16 +120,13 @@ def annual_allowance_net_income(person, period, parameters): # below so the callers can deduct or restore member relief once. income_after_net_pay = np.maximum(0, income_before_loss - relievable_net_pay) actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_after_net_pay)) - # Add back the loss computed using synthetic pay before deducting the - # actual-pay loss. Supplied ANI has its own path above. For other people, - # net-pay/loss ordering also applies before the NICs cap starts. + # Replace the original loss with the loss after relievable net pay. + # Supplied ANI has its own path above. This ordering also applies before + # the NICs cap starts and when the actual legacy return is zero. reconstructed = np.maximum( 0, net_income + person("trade_loss_relief_against_general_income", period) - - removed_taxable_pay - actual_loss_relief, ) - return np.where( - (returned > 0) | (relievable_net_pay > 0), reconstructed, net_income - ) + return np.where(relievable_net_pay > 0, reconstructed, net_income) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py index 4d84d8c253..2d2a3ab5fe 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -13,8 +13,8 @@ class pension_annual_allowance_adjusted_income(Variable): "and exclude taxable lump-sum death benefits already included in " "income. The pension input amount can be supplied to include statutory " "defined-benefit accrual; its default is a cash-contribution proxy. " - "The NICs-only returned salary sacrifice is removed from both model " - "income and member contributions. All actual own contributions are " + "The NICs-only excess enters neither model income nor member " + "contributions. All actual own contributions are " "subtracted, including unrelievable contributions from age 75." ) definition_period = YEAR @@ -22,9 +22,8 @@ class pension_annual_allowance_adjusted_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/4" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_income = annual_allowance_net_income(person, period, parameters) - actual_member_contributions = person("pension_contributions", period) - returned + actual_member_contributions = person("pension_contributions", period) return ( actual_income + person("pension_contributions_for_annual_allowance", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index 374353361c..1bcdba74a7 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -1,9 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import ( - annual_allowance_net_income, - pension_contributions_relief_amount, - relevant_uk_earnings, -) +from policyengine_uk.utils.pensions import annual_allowance_net_income class pension_annual_allowance_threshold_income(Variable): @@ -11,9 +7,9 @@ class pension_annual_allowance_threshold_income(Variable): entity = Person label = "Threshold income for the pension annual allowance" documentation = ( - "FA 2004 s. 228ZA(5) threshold income. Remove the NICs-only returned " - "salary sacrifice and recompute income-dependent Step 2 relief on " - "actual member contributions and relevant earnings. This combines " + "FA 2004 s. 228ZA(5) threshold income. Use actual pay and member " + "contributions, retaining income-dependent Step 2 relief ordering. " + "The NICs-only excess is not ordinary income. This combines " "the net-pay deduction from statutory net income with gross RAS " "relief, including the basic amount and age limit. Add back full " "salary sacrifice before the NICs cap, except the supplied amount " @@ -26,7 +22,6 @@ class pension_annual_allowance_threshold_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/5" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_sacrifice = person( "pension_contributions_via_salary_sacrifice_from_pay", period ) @@ -34,25 +29,9 @@ def formula(person, period, parameters): 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) ) relevant_sacrifice = max_(0, actual_sacrifice - grandfathered) - # NICs reclassification changes neither income-tax earnings nor the - # actual member contributions. Its relief need not equal the returned - # amount: after 75 there is none, and low earnings can use basic RAS. - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned - relief = where( - returned > 0, - pension_contributions_relief_amount( - actual_member_contributions, - actual_earnings, - person("personal_pension_contributions", period), - person("age", period), - parameters(period).gov.hmrc, - ), - person("pension_contributions_relief", period), - ) return ( annual_allowance_net_income(person, period, parameters) - - relief + - person("pension_contributions_relief", period) + relevant_sacrifice - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 9463d75e7f..7c4eace161 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -33,12 +33,10 @@ class pension_contributions_for_annual_allowance(Variable): unit = GBP def formula(person, period, parameters): - # The returned sacrifice is still an employer payment in income-tax - # law. Remove it from member contributions before the age exclusion, - # then include the full employer sacrifice once at every age. - member_contributions = person("pension_contributions", period) - person( - "salary_sacrifice_returned_to_income", period - ) + # A supplied ordinary return is an actual member payment, while the + # NICs-only excess is not. Include full employer sacrifice separately + # once at every age, retaining the age exclusion for member payments. + member_contributions = person("pension_contributions", period) under_age_limit = ( person("age", period) < parameters( From 3531d110b5a821b32f10087e02720af1757cdf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:12:15 +0100 Subject: [PATCH 042/141] Cover childcare and actual-return pension interactions --- changelog.d/2454.fixed.md | 2 +- .../test_salary_sacrifice_cap_reform.py | 10 ++-- .../salary_sacrifice_returned_to_income.yaml | 60 +++++++++++++++++++ .../salary_sacrifice_returned_to_income.py | 2 + 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/changelog.d/2454.fixed.md b/changelog.d/2454.fixed.md index 6a578cabd3..c4bf9c227a 100644 --- a/changelog.d/2454.fixed.md +++ b/changelog.d/2454.fixed.md @@ -1 +1 @@ -- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, and retained actual supplied ordinary returns in the annual-allowance calculations. +- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, retained actual supplied ordinary returns in the annual-allowance calculations, and preserved the childcare income tests in #2173. diff --git a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py index 12e39f3688..d292b0c2a8 100644 --- a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -4,11 +4,11 @@ This tests the £2,000 salary sacrifice cap policy announced in Autumn Budget 2025, which takes effect from April 2029. -Methodology (matching blog at https://policyengine.org/uk/research/uk-salary-sacrifice-cap): +Current NI-only model routing (historical population benchmarks below): - Excess above £2,000 is NIC-only earnings; pension contributions stay employer payments - Ordinary income-tax treatment is unchanged; only NIC exemption is capped - Broad-base haircut: employers spread NI costs across ALL workers (~0.16% of employment income) -- Revenue comes from NI on the redirected excess +- Revenue comes from NI on the excess included in NI earnings Expected revenue: ~£3.3 billion in 2029-30 @@ -60,7 +60,7 @@ def test_salary_sacrifice_cap_revenue_impact(baseline_simulation, reform_simulat """ Test that the £2,000 salary sacrifice cap raises ~£3.3 billion. - This matches the PolicyEngine blog methodology: + The current NI-only model routing is: - Excess is included only in NI earnings - Broad-base haircut reduces all workers' income as a separate assumption - Employer pension contributions retain their income-tax treatment @@ -148,7 +148,7 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): @pytest.mark.microsimulation -def test_excess_redirected_to_pension(reform_simulation): +def test_excess_included_in_ni_earnings(reform_simulation): """ Test that full excess is included in NIC earnings. @@ -211,7 +211,7 @@ def test_affected_population(reform_simulation): @pytest.mark.microsimulation -def test_full_excess_redirected(reform_simulation): +def test_full_excess_included_in_ni_earnings(reform_simulation): """ Test that the full excess is included in NIC earnings (no targeted haircut). diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml index 66b488d30e..8ac4e451ce 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml @@ -83,3 +83,63 @@ salary_sacrifice_returned_to_income: 0 pension_contributions_via_salary_sacrifice_adjusted: 5_000 employment_income: 40_000 + +# Issue #2173: cash pay is already after the contractual employer sacrifice. +# The unchanged 0.16% broad-base haircut reduces 97,000 by 155.20; the 8,000 +# NIC-only excess does not enter ANI or become a member pension payment. +- name: NI-only sacrifice excess preserves both childcare income tests, 2173 + period: 2029 + absolute_error_margin: 0.02 + input: + people: + parent: + age: 40 + employment_income_before_lsr: 97_000 + pension_contributions_via_salary_sacrifice: 10_000 + child: + age: 2 + benunits: + family: + members: [parent, child] + households: + household: + members: [parent, child] + region: LONDON + output: + employment_income: [96_844.80, 0] + adjusted_net_income: [96_844.80, 0] + ni_class_1_income: [104_844.80, 0] + salary_sacrifice_pension_ni_excess: [8_000, 0] + salary_sacrifice_returned_to_income: [0, 0] + employee_pension_contributions: [0, 0] + tax_free_childcare_meets_income_requirements: [true, false] + extended_childcare_entitlement_meets_income_requirements: [true, false] + +# Conditional supplied-ANI contract, paired with the parent's 2026 SSP case. +# The cap is inactive, so there is no broad-base haircut. The supplied return +# is actual pay/member cash: 19,500 + 500 pay and 21,500 + 500 member payments. +- name: Actual returned pay and SSP retain supplied-ANI annual allowance treatment + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income_before_lsr: 19_500 + salary_sacrifice_returned_to_income: 500 + statutory_sick_pay: 2_000 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 21_500 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5_000 + output: + employment_income: 20_000 + employment_benefits: 2_000 + employee_pension_contributions: 22_000 + pension_contributions_relief: 22_000 + net_income: 27_000 + pension_contributions_for_annual_allowance: 22_000 + pension_annual_allowance_threshold_income: 5_000 + pension_annual_allowance_adjusted_income: 27_000 diff --git a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py index 13a68de660..386eb3f960 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py @@ -2,6 +2,8 @@ class salary_sacrifice_returned_to_income(Variable): + """Legacy supplied input, defaulting to zero; never computed by the model.""" + label = "Salary sacrifice actually returned to ordinary income" documentation = ( "Legacy ordinary-pay/member-payment input, defaulting to zero. The " From f34503a013f3eefcd2fe4e465eaf5b7642163ed6 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 19:31:06 +0200 Subject: [PATCH 043/141] Uprate pension_contributions_via_salary_sacrifice_pre_2015 by average earnings Main added this input (e28e3b643) declaring obr.average_earnings without a YAML entry, so the guard fails once main is merged. It is the pre-9 July 2015 share of pension_contributions_via_salary_sacrifice, which the YAML already uprates by the same index. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uprate-four-remaining-columns.fixed.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.d/uprate-four-remaining-columns.fixed.md b/changelog.d/uprate-four-remaining-columns.fixed.md index d3a71d0683..81ee518f08 100644 --- a/changelog.d/uprate-four-remaining-columns.fixed.md +++ b/changelog.d/uprate-four-remaining-columns.fixed.md @@ -1 +1 @@ -- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate eleven columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. +- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate twelve columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income), plus `pension_contributions_via_salary_sacrifice_pre_2015` (average earnings). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 5670d68fde..6dbe8b66e8 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -33,6 +33,8 @@ gov.economic_assumptions.yoy_growth.obr.average_earnings: - employment_income - employment_income_before_lsr - pension_contributions_via_salary_sacrifice +# The pre-9 July 2015 share of the line above (FA 2004 s. 228ZA(6)). +- pension_contributions_via_salary_sacrifice_pre_2015 - personal_pension_contributions - student_loan_repayments gov.economic_assumptions.yoy_growth.obr.consumer_price_index: From 5b6a38cfd9a7863f581c3b2ce8cd6a3f5768d6b4 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 20:00:59 +0200 Subject: [PATCH 044/141] Keep carried-forward PIP categories when no points are supplied The points formulas on pip_dl_category and pip_m_category stopped an earlier year's supplied category carrying forward, so a 2025 award read as NONE in 2026. Points supplied for the year now decide the category; without them, the most recent earlier input (category or points) does. Also cite regs 5(3) and 6(3) for the thresholds and update the 2025 amounts in the points tests to the 2025-26 rates on main. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1069.added.md | 2 +- .../points_thresholds/enhanced.yaml | 2 +- .../points_thresholds/standard.yaml | 2 +- .../mobility/points_thresholds/enhanced.yaml | 2 +- .../mobility/points_thresholds/standard.yaml | 2 +- .../policy/baseline/gov/dwp/pip_points.yaml | 64 +++++++++++++++++-- policyengine_uk/utils/pip.py | 54 ++++++++++++++++ policyengine_uk/utils/supplied_inputs.py | 4 ++ .../variables/input/pip_dl_category.py | 17 +---- .../variables/input/pip_m_category.py | 17 +---- 10 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 policyengine_uk/utils/pip.py diff --git a/changelog.d/1069.added.md b/changelog.d/1069.added.md index 173fa54df2..0634713134 100644 --- a/changelog.d/1069.added.md +++ b/changelog.d/1069.added.md @@ -1 +1 @@ -Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points when the category is not provided as input. +Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points supplied for the year when the category is not provided as input. Without points for the year, a category supplied for an earlier year still carries forward. diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml index a8d19f63b8..7636be7603 100644 --- a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml @@ -5,5 +5,5 @@ metadata: unit: integer label: PIP daily living enhanced rate points threshold reference: - - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(2)(b) + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(3)(b) href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml index 48b2350927..40ca999230 100644 --- a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml @@ -5,7 +5,7 @@ metadata: unit: integer label: PIP daily living standard rate points threshold reference: - - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(2)(a) + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(3)(a) href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 - title: PIP assessment guide for assessment providers — Part 2 (Daily Living) href: https://www.gov.uk/government/publications/personal-independence-payment-assessment-guide-for-assessment-providers diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml index 44562a518e..8a388e39ce 100644 --- a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml @@ -5,5 +5,5 @@ metadata: unit: integer label: PIP mobility enhanced rate points threshold reference: - - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(2)(b) + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(3)(b) href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml index d8c1274fcf..4d550e55f7 100644 --- a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml @@ -5,5 +5,5 @@ metadata: unit: integer label: PIP mobility standard rate points threshold reference: - - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(2)(a) + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(3)(a) href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml index 777e0cd479..2779da57dc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml @@ -24,7 +24,7 @@ pip_dl_points: 8 output: pip_dl_category: STANDARD - pip_dl: 3842.28 + pip_dl: 3842.80 - name: PIP daily living — 11 points stays at standard period: 2025 @@ -33,7 +33,7 @@ pip_dl_points: 11 output: pip_dl_category: STANDARD - pip_dl: 3842.28 + pip_dl: 3842.80 - name: PIP daily living — 12 points hits the enhanced threshold period: 2025 @@ -51,7 +51,7 @@ pip_m_points: 8 output: pip_m_category: STANDARD - pip_m: 1517.88 + pip_m: 1518.40 - name: PIP mobility — 12 points hits the enhanced threshold period: 2025 @@ -60,7 +60,7 @@ pip_m_points: 12 output: pip_m_category: ENHANCED - pip_m: 4006.08 + pip_m: 4006.60 - name: PIP — input category overrides points-derived category period: 2025 @@ -71,3 +71,59 @@ output: pip_dl_category: ENHANCED pip_dl: 5740.80 + +- name: PIP — categories supplied for an earlier year carry forward without points + period: 2026 + input: + pip_dl_category: + 2025: ENHANCED + pip_m_category: + 2025: STANDARD + output: + pip_dl_category: ENHANCED + pip_m_category: STANDARD + +- name: PIP — points supplied for the year replace an earlier year's category + period: 2026 + input: + pip_dl_category: + 2025: ENHANCED + pip_dl_points: + 2026: 8 + pip_m_category: + 2025: STANDARD + pip_m_points: + 2026: 0 + output: + pip_dl_category: STANDARD + pip_m_category: NONE + +- name: PIP — points supplied after an earlier category carry forward and win + period: 2026 + input: + pip_dl_category: + 2024: STANDARD + pip_dl_points: + 2025: 12 + pip_m_category: + 2024: ENHANCED + pip_m_points: + 2025: 7 + output: + pip_dl_category: ENHANCED + pip_m_category: NONE + +- name: PIP — a category supplied after earlier points carries forward and wins + period: 2026 + input: + pip_dl_points: + 2024: 12 + pip_dl_category: + 2025: NONE + pip_m_points: + 2024: 0 + pip_m_category: + 2025: ENHANCED + output: + pip_dl_category: NONE + pip_m_category: ENHANCED diff --git a/policyengine_uk/utils/pip.py b/policyengine_uk/utils/pip.py new file mode 100644 index 0000000000..c66ac41499 --- /dev/null +++ b/policyengine_uk/utils/pip.py @@ -0,0 +1,54 @@ +"""Shared logic for the PIP component categories.""" + +from policyengine_core.periods import Period + +from policyengine_uk.model_api import select +from policyengine_uk.utils.supplied_inputs import ( + supplied_input, + supplied_input_periods, +) +from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory + + +def pip_category(person, period: Period, thresholds, component: str): + """The PIP category for ``component`` ("dl" or "m") in ``period``. + + A category supplied for the period is an input, so this only runs when + there is none. Points supplied for the period are then the evidence. If + they are not, the most recent earlier input decides: a category supplied + in an earlier year carries forward, as it would for an input-only + variable, unless points were supplied in a later year than that + category. Points, like other inputs, carry forward from the latest year + they were supplied. Points default to zero, below the standard + threshold, so with neither input the category is NONE. + + Inputs are stored for the whole population, so "supplied" is decided + per year, not per person. If points are supplied for a year for anyone, + everyone's category that year comes from points, and a person given + only an earlier category reads the default of zero points (NONE). Give + such a person their points or category for that year as well. + """ + category_variable = f"pip_{component}_category" + points_variable = f"pip_{component}_points" + if supplied_input(person, points_variable, period) is None: + earlier_categories = [ + p + for p in supplied_input_periods(person, category_variable) + if p.start < period.start + ] + earlier_points = [ + p + for p in supplied_input_periods(person, points_variable) + if p.start < period.start + ] + if earlier_categories and ( + not earlier_points + or earlier_categories[-1].start >= earlier_points[-1].start + ): + return supplied_input(person, category_variable, earlier_categories[-1]) + points = person(points_variable, period) + return select( + [points >= thresholds.enhanced, points >= thresholds.standard], + [PIPCategory.ENHANCED, PIPCategory.STANDARD], + default=PIPCategory.NONE, + ) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index a6a7600e6c..699d6514fc 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -43,6 +43,10 @@ "bus_fare_spending", "bus_in_london_trips", "other_local_bus_trips", + "pip_dl_category", + "pip_dl_points", + "pip_m_category", + "pip_m_points", "local_bus_trips", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", diff --git a/policyengine_uk/variables/input/pip_dl_category.py b/policyengine_uk/variables/input/pip_dl_category.py index 7b74298a8a..d9f777e31e 100644 --- a/policyengine_uk/variables/input/pip_dl_category.py +++ b/policyengine_uk/variables/input/pip_dl_category.py @@ -1,10 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pip import pip_category from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory class pip_dl_category(Variable): label = "PIP (daily living) category" - documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input, the category is derived from `pip_dl_points` against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`." + documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from `pip_dl_points` supplied for the year against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`. Without points for the year, a category supplied for an earlier year carries forward, unless points were supplied for a later year than that category." entity = Person definition_period = YEAR value_type = Enum @@ -12,17 +13,5 @@ class pip_dl_category(Variable): default_value = PIPCategory.NONE def formula(person, period, parameters): - points = person("pip_dl_points", period) thresholds = parameters(period).gov.dwp.pip.daily_living.points_thresholds - return select( - [ - points >= thresholds.enhanced, - points >= thresholds.standard, - points < thresholds.standard, - ], - [ - PIPCategory.ENHANCED, - PIPCategory.STANDARD, - PIPCategory.NONE, - ], - ) + return pip_category(person, period, thresholds, "dl") diff --git a/policyengine_uk/variables/input/pip_m_category.py b/policyengine_uk/variables/input/pip_m_category.py index ae3749601a..8c150ef4f7 100644 --- a/policyengine_uk/variables/input/pip_m_category.py +++ b/policyengine_uk/variables/input/pip_m_category.py @@ -1,10 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pip import pip_category from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory class pip_m_category(Variable): label = "PIP (mobility) category" - documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input, the category is derived from `pip_m_points` against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`." + documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from `pip_m_points` supplied for the year against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`. Without points for the year, a category supplied for an earlier year carries forward, unless points were supplied for a later year than that category." entity = Person definition_period = YEAR value_type = Enum @@ -12,17 +13,5 @@ class pip_m_category(Variable): default_value = PIPCategory.NONE def formula(person, period, parameters): - points = person("pip_m_points", period) thresholds = parameters(period).gov.dwp.pip.mobility.points_thresholds - return select( - [ - points >= thresholds.enhanced, - points >= thresholds.standard, - points < thresholds.standard, - ], - [ - PIPCategory.ENHANCED, - PIPCategory.STANDARD, - PIPCategory.NONE, - ], - ) + return pip_category(person, period, thresholds, "m") From 0d8ed36319d82384ee7c8f7c2e2fc8cd0ccfffb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:51:08 +0100 Subject: [PATCH 045/141] Refactor NI parameters into canonical marginal scales --- .../222-ni-scale-parameters.changed.md | 1 + docs/book/index.md | 4 + docs/book/policy/model-baseline.md | 12 +- .../gov/hmrc/national-insurance-scales.md | 71 +++ .../gov/hmrc/create_threshold_indexation.py | 45 +- .../class_1/employee_scale.yaml | 240 ++++++++++ .../class_1/employer_scale.yaml | 157 +++++++ .../class_1/rates/employee/additional.yaml | 18 - .../class_1/rates/employee/main.yaml | 33 -- .../class_1/rates/employer.yaml | 35 -- .../class_1/thresholds/primary_threshold.yaml | 89 ---- .../thresholds/secondary_threshold.yaml | 103 ----- .../thresholds/upper_earnings_limit.yaml | 79 ---- .../class_4/contribution_scale.yaml | 328 ++++++++++++++ .../class_4/rates/additional.yaml | 33 -- .../class_4/rates/main.yaml | 41 -- .../thresholds/lower_profits_limit.yaml | 124 ------ .../thresholds/upper_profits_limit.yaml | 109 ----- .../labour_supply_responses/aliases.py | 3 +- policyengine_uk/tax_benefit_system.py | 25 +- .../ni_legacy_parameter_metadata.json | 266 ++++++++++++ .../fixtures/ni_metadata_api_contract.py | 179 ++++++++ .../test_ni_health_and_social_care_levy.py | 9 +- .../tests/test_ni_metadata_compat.py | 86 ++++ .../tests/test_ni_parameter_scales.py | 409 ++++++++++++++++++ .../tests/test_ni_scale_equivalence.py | 156 +++++++ .../tests/test_ni_threshold_freeze.py | 26 +- .../tests/test_threshold_indexation.py | 134 ++++-- policyengine_uk/utils/dated_reforms.py | 15 +- policyengine_uk/utils/ni_parameter_scales.py | 334 ++++++++++++++ policyengine_uk/utils/parameters.py | 14 +- .../employer_ni/adjusted_employer_cost.py | 4 +- .../employer_ni/baseline_employer_cost.py | 4 +- .../employer_ni_fixed_employer_cost_change.py | 8 +- .../gov/dwp/carers_allowance_earnings.py | 6 +- .../gov/dwp/esa_exempt_work_earnings.py | 20 +- ...minimum_income_floor_national_insurance.py | 22 +- .../class_1/ni_class_1_employee_additional.py | 8 +- .../class_1/ni_class_1_employee_primary.py | 10 +- .../class_1/ni_class_1_employer.py | 6 +- .../national_insurance/class_4/ni_class_4.py | 8 +- .../class_4/ni_class_4_main.py | 6 +- .../class_4/ni_class_4_maximum.py | 8 +- .../salary_sacrifice_pension_ni_employee.py | 8 +- .../salary_sacrifice_pension_ni_employer.py | 2 +- 45 files changed, 2497 insertions(+), 801 deletions(-) create mode 100644 changelog.d/222-ni-scale-parameters.changed.md create mode 100644 docs/book/programs/gov/hmrc/national-insurance-scales.md create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml create mode 100644 policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json create mode 100644 policyengine_uk/tests/fixtures/ni_metadata_api_contract.py create mode 100644 policyengine_uk/tests/test_ni_metadata_compat.py create mode 100644 policyengine_uk/tests/test_ni_parameter_scales.py create mode 100644 policyengine_uk/tests/test_ni_scale_equivalence.py create mode 100644 policyengine_uk/utils/ni_parameter_scales.py diff --git a/changelog.d/222-ni-scale-parameters.changed.md b/changelog.d/222-ni-scale-parameters.changed.md new file mode 100644 index 0000000000..6bd8e6ab56 --- /dev/null +++ b/changelog.d/222-ni-scale-parameters.changed.md @@ -0,0 +1 @@ +- Store marginal National Insurance schedules as parameter scales while preserving contribution calculations and existing scalar reform paths. diff --git a/docs/book/index.md b/docs/book/index.md index 9b4813fd6b..e328647096 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -62,3 +62,7 @@ revenue = ( ) f"Revenue: £{round(revenue / 1e9, 1)}bn" ``` + +## Model reference + +- [National Insurance parameter scales](programs/gov/hmrc/national-insurance-scales.md) diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 29f7dafb3c..faef8508ca 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -8,7 +8,7 @@ PolicyEngine UK models the UK tax and benefit system as of 2025, incorporating m ### Budget 2025 threshold freezes -Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. Finance Act 2021 s.5, as amended by Finance Act 2023 s.5 and Finance Act 2026 s.10, fixes the [personal allowance](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml) at £12,570 and the [basic rate limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml) at £37,700 for 2022-23 to 2030-31. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £5,000 a year through 2030-31. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. +Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. Finance Act 2021 s.5, as amended by Finance Act 2023 s.5 and Finance Act 2026 s.10, fixes the [personal allowance](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml) at £12,570 and the [basic rate limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml) at £37,700 for 2022-23 to 2030-31. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £5,000 a year through 2030-31. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. From 2031-32 the model indexes these thresholds by September CPI, in [`create_threshold_indexation.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The personal allowance and basic rate limit follow Income Tax Act 2007 ss.57 and 21 year by year: each rises by the CPI increase to the previous September, with the allowance's increase rounded up to a multiple of £10 and the limit rounded up to a multiple of £100. The government says the National Insurance thresholds will also be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI 2026/231, para 5.1), and it has kept them aligned with income tax through the freeze (HMRC's Budget 2025 policy paper on the threshold freeze; Explanatory Memorandum to SI 2026/231, paras 5.14 and 5.15). The model assumes the alignment continues, round-ups included: the primary threshold and lower profits limit equal the personal allowance, and the upper earnings and profits limits equal the higher rate threshold (the personal allowance plus the basic rate limit). It also assumes the secondary threshold rises by September CPI, unrounded; the Autumn Budget 2024 policy paper says only that it will be increased in line with CPI. None of these National Insurance rules after 2030-31 is law. @@ -16,7 +16,7 @@ From 2031-32 the model indexes these thresholds by September CPI, in [`create_th ### Autumn Budget 2024 -The government increased the [employer National Insurance rate from 13.8% to 15%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml#L30-L35) and reduced the [employer secondary threshold from £9,100 to £5,000 annually](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml#L26-L30), both taking effect in fiscal year 2025-26. +The government increased the [employer National Insurance rate from 13.8% to 15%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml) and reduced the [employer secondary threshold from £9,100 to £5,000 annually](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml), both taking effect in fiscal year 2025-26. ### Universal Credit rebalancing @@ -30,7 +30,7 @@ Annual benefit uprating applied 4.1% increases to state pensions and 1.7% to wor ### Autumn Statement 2023 -The government announced further National Insurance cuts, reducing the [employee main rate from 10% to 8%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L24-L29) and the [self-employed Class 4 rate from 9% to 6%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml#L26-L33) in fiscal year 2024-25. [Class 2 National Insurance contributions were abolished](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_2/flat_rate.yaml#L51-L56) for self-employed people, with the flat rate set to £0 in fiscal year 2024-25. +The government announced further National Insurance cuts, reducing the [employee main rate from 10% to 8%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) and the [self-employed Class 4 rate from 9% to 6%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml) in fiscal year 2024-25. [Class 2 National Insurance contributions were abolished](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_2/flat_rate.yaml#L51-L56) for self-employed people, with the flat rate set to £0 in fiscal year 2024-25. ### Capital gains tax changes (Autumn Budget 2024) @@ -42,7 +42,7 @@ The government increased the [child benefit high income tax charge threshold fro ### Autumn Statement 2023 (Employee NI cut) -An initial National Insurance cut reduced the [employee main rate from 12% to 10%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L18-L23) in fiscal year 2024-25. +An initial National Insurance cut reduced the [employee main rate from 12% to 10%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) in fiscal year 2024-25. ## 2023 reforms @@ -54,11 +54,11 @@ The government reduced the [additional rate income tax threshold from £150,000 ### Spring Statement 2022 -The government announced an increase in the [National Insurance primary threshold from £9,880 to £12,570](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml#L17-L22), aligning it with the personal allowance in fiscal year 2022-23. +The government announced an increase in the [National Insurance primary threshold from £9,880 to £12,570](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml), aligning it with the personal allowance in fiscal year 2022-23. ### Autumn Budget 2021 (Health and Social Care Levy) -The Health and Social Care Levy Act 2021 raised the [employee main rate from 12% to 13.25%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L4-L17) and the [employer rate from 13.8% to 15.05%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml#L18-L29) for fiscal year 2022-23. The Health and Social Care Levy (Repeal) Act 2022 restored 12% and 13.8% for earnings paid from 6 November 2022. The model reads each fiscal year's rates at 30 April, so 2022-23 carries the levy rates for the whole year ([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)). +The Health and Social Care Levy Act 2021 raised the [employee main rate from 12% to 13.25%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) and the [employer rate from 13.8% to 15.05%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml) for fiscal year 2022-23. The Health and Social Care Levy (Repeal) Act 2022 restored 12% and 13.8% for earnings paid from 6 November 2022. The model reads each fiscal year's rates at 30 April, so 2022-23 carries the levy rates for the whole year ([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)). ### Mini-Budget 2022 (September) diff --git a/docs/book/programs/gov/hmrc/national-insurance-scales.md b/docs/book/programs/gov/hmrc/national-insurance-scales.md new file mode 100644 index 0000000000..f1c9599305 --- /dev/null +++ b/docs/book/programs/gov/hmrc/national-insurance-scales.md @@ -0,0 +1,71 @@ +# National Insurance parameter scales + +PolicyEngine UK stores three marginal National Insurance (NI) schedules as +parameter scales. Each schedule holds its contribution rates and the thresholds +at which they apply. The schedules retain their dated values and units. + +All paths below start with `gov.hmrc.national_insurance.`. Bracket indices start +at zero; bracket zero supplies the initial zero-rate band. + +| Scale | Contribution bands | Threshold period in the parameter files | +| --- | --- | --- | +| `class_1.employee_scale` | Primary rate in bracket 1; additional rate in bracket 2 | Week | +| `class_1.employer_scale` | Employer rate in bracket 1 | Week | +| `class_4.contribution_scale` | Main rate in bracket 1; additional rate in bracket 2 | Year | + +The contribution formulas retain their period conversions. Changing the +parameter layout does not make a weekly threshold an annual amount. Class 2's +flat contribution amount and its eligibility thresholds remain scalar +parameters. + +## Reform paths + +Use the following canonical paths when specifying new rate or threshold reforms. +The old scalar paths remain live views of the corresponding scale components. +Updating either path changes the same dated parameter history. + +| Existing scalar path | Canonical scale component | +| --- | --- | +| `class_1.rates.employee.main` | `class_1.employee_scale[1].rate` | +| `class_1.rates.employee.additional` | `class_1.employee_scale[2].rate` | +| `class_1.thresholds.primary_threshold` | `class_1.employee_scale[1].threshold` | +| `class_1.thresholds.upper_earnings_limit` | `class_1.employee_scale[2].threshold` | +| `class_1.rates.employer` | `class_1.employer_scale[1].rate` | +| `class_1.thresholds.secondary_threshold` | `class_1.employer_scale[1].threshold` | +| `class_4.rates.main` | `class_4.contribution_scale[1].rate` | +| `class_4.rates.additional` | `class_4.contribution_scale[2].rate` | +| `class_4.thresholds.lower_profits_limit` | `class_4.contribution_scale[1].threshold` | +| `class_4.thresholds.upper_profits_limit` | `class_4.contribution_scale[2].threshold` | + +The bracket syntax above identifies a leaf in a reform path. Python callables +access a raw scale through its `brackets` list, for example +`parameters.gov.hmrc.national_insurance.class_1.employee_scale.brackets[1].rate`. +Existing scalar attribute chains and leaf updates remain supported. The model's +scalar views expose the old names, current dated values, descriptions and units. + +Apply scalar changes through the rate or threshold leaf. The compatibility views +do not provide a second editable copy of the NI schedules. Replacing, deleting or +reparenting legacy subtrees requires an explicit structural migration using the +canonical scales; conflicting legacy replacements raise a migration error. + +## Contribution outputs + +The scales preserve the model's contribution components. Employee Class 1 NI +still adds `ni_class_1_employee_primary` and +`ni_class_1_employee_additional`. These outputs retain their separate input +overrides. Class 4 still calculates its main and additional contributions and +applies the existing annual-maximum rules. Employer NI retains its existing +pension-contribution switch. + +The parameter migration preserves the existing calculation rules, liability +conditions and rounding. + +## Maintaining the schedules + +Edit the canonical scale components when changing the model. Their dated values, +references, units and uprating metadata supply the legacy scalar views. The NI +threshold generator writes to these canonical leaves. Parameter processing visits +each numerical history once; metadata discovery also exposes the legacy names. + +For NI rate reforms in behavioural scenarios, see +[Labour supply dynamics](../../../usage/dynamics.md). diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index e6be77d34e..09abf0de6d 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -124,11 +124,11 @@ INDEXED_THRESHOLDS = ( "gov.hmrc.income_tax.allowances.personal_allowance.amount", "gov.hmrc.income_tax.rates.uk[1].threshold", - "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", - "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", - "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", - "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", - "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", + "gov.hmrc.national_insurance.class_1.employee_scale[1].threshold", + "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold", + "gov.hmrc.national_insurance.class_1.employer_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[2].threshold", ) @@ -307,18 +307,18 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: """ increases = september_cpi_increases(parameters) income_tax = parameters.gov.hmrc.income_tax - class_1 = parameters.gov.hmrc.national_insurance.class_1.thresholds - class_4 = parameters.gov.hmrc.national_insurance.class_4.thresholds + class_1 = parameters.gov.hmrc.national_insurance.class_1 + class_4 = parameters.gov.hmrc.national_insurance.class_4 personal_allowance_parameter = income_tax.allowances.personal_allowance.amount basic_rate_limit_parameter = income_tax.rates.uk.brackets[1].threshold indexed = [ personal_allowance_parameter, basic_rate_limit_parameter, - class_1.primary_threshold, - class_1.upper_earnings_limit, - class_1.secondary_threshold, - class_4.lower_profits_limit, - class_4.upper_profits_limit, + class_1.employee_scale.brackets[1].threshold, + class_1.employee_scale.brackets[2].threshold, + class_1.employer_scale.brackets[1].threshold, + class_4.contribution_scale.brackets[1].threshold, + class_4.contribution_scale.brackets[2].threshold, ] # From the earliest last value in the files, so the income tax amounts # cover every year a NICs threshold takes from them. @@ -337,25 +337,34 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: year: personal_allowance[year] + basic_rate_limit[year] for year in years } secondary_threshold = tax_year_path( - class_1.secondary_threshold, years, increases, index_secondary_threshold + class_1.employer_scale.brackets[1].threshold, + years, + increases, + index_secondary_threshold, ) write_unstated_tax_years(personal_allowance_parameter, personal_allowance) write_unstated_tax_years(basic_rate_limit_parameter, basic_rate_limit) - write_unstated_tax_years(class_4.lower_profits_limit, personal_allowance) - write_unstated_tax_years(class_4.upper_profits_limit, higher_rate_threshold) write_unstated_tax_years( - class_1.primary_threshold, + class_4.contribution_scale.brackets[1].threshold, personal_allowance + ) + write_unstated_tax_years( + class_4.contribution_scale.brackets[2].threshold, higher_rate_threshold + ) + write_unstated_tax_years( + class_1.employee_scale.brackets[1].threshold, {year: weekly_equivalent(value) for year, value in personal_allowance.items()}, ) write_unstated_tax_years( - class_1.upper_earnings_limit, + class_1.employee_scale.brackets[2].threshold, { year: weekly_equivalent(value) for year, value in higher_rate_threshold.items() }, ) - write_unstated_tax_years(class_1.secondary_threshold, secondary_threshold) + write_unstated_tax_years( + class_1.employer_scale.brackets[1].threshold, secondary_threshold + ) for parameter in indexed: (parameter.metadata or {}).pop(FILES_LAST_TAX_YEAR, None) return parameters diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml new file mode 100644 index 0000000000..9452c0db1d --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml @@ -0,0 +1,240 @@ +description: Class 1 employee marginal contribution schedule. +metadata: + label: Class 1 employee marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + unit: currency-GBP + period: week + rate: + values: + 0001-01-01: 0 + metadata: + unit: /1 + - threshold: + description: The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance. + # From 6 July 2022 the model stores the annual earnings period threshold + # divided by 52 (12,570 / 52, rounded to 241.73), as SI 2001/1004 reg 11(3)(c) + # does for periods of whole weeks; 52 weeks give £12,569.96. The weekly amount + # in reg 10(c) is £242. + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model keeps the + # threshold at the personal allowance over 52, to the penny, as for 241.73 + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the PT aligned with the personal allowance, which Income + # Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the PT will + # be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI + # 2026/231 para 5.1). That is announced policy, not law; keeping it equal to + # the allowance, round-up included, is a modelling assumption consistent with + # it. The file has no uprating. + values: + 2015-04-06: 155 + 2016-04-06: 155 + 2017-04-06: 157 + 2018-04-06: 162 + 2019-04-06: 166 + 2020-04-06: 183 + 2021-04-06: 184 + 2022-04-06: + value: 190 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2022(6) + href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a + 2022-07-01: + value: 241.73 + metadata: + reference: + - title: Spring Statement 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1062486/Spring_Statement_2022_Web_Accessible.pdf + 2026-04-06: + value: 241.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(c) and reg 11(3), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 241.73 + metadata: + reference: + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI primary threshold + unit: currency-GBP + period: week + reference: + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit. + values: + 2015-04-01: 0.12 + 2022-04-06: + value: 0.1325 + metadata: + reference: + - title: Health and Social Care Levy Act 2021 s.5(2)(a)(i) + href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted + - title: Autumn Budget and Spending Review 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf + 2022-11-06: + value: 0.12 + metadata: + reference: + - title: Health and Social Care Levy Act (Repeal) 2022 + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2024-01-01: + value: 0.1 + metadata: + reference: + - title: "Autumn Statement 2023: National Insurance Factsheet" + href: https://www.gov.uk/government/publications/autumn-statement-2023-national-insurance-factsheet/autumn-statement-2023-national-insurance-factsheet + 2024-04-01: + value: 0.08 + metadata: + reference: + - title: National Insurance Contributions (Reduction in Rates) Act 2024 s 1 + href: https://www.legislation.gov.uk/ukpga/2024/5/section/1 + metadata: + label: NI class 1 main rate + unit: /1 + reference: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - threshold: + description: The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance. + # From 2021-22 the model stores the annual earnings period limit divided by 52 + # (50,270 / 52, rounded to 966.73), as SI 2001/1004 reg 11(2A)(c) does for + # periods of whole weeks; 52 weeks give £50,269.96. The weekly amount in + # reg 10(b) is £967. + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model keeps the + # limit at the higher rate threshold (personal allowance plus basic rate + # limit) over 52, to the penny, as for 966.73 + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # UEL has been aligned with the higher rate threshold since April 2009, and + # for 2026-27 "will remain at a level equivalent to the annual HRT" + # (Explanatory Memorandum to SI 2026/231 paras 5.14-5.15); para 5.1 says it + # will be uprated by September CPI after 2030-31. Income Tax Act 2007 ss21 and + # 57 index the higher rate threshold by September CPI from 2031-32. Keeping + # the UEL equal to it, round-ups included, is a modelling assumption + # consistent with that announced policy, not law. The file has no uprating. + values: + 2015-04-06: 815 + 2016-04-06: 827 + 2017-04-06: 866 + 2018-04-06: 892 + 2019-04-06: 962 + 2020-04-06: 962 + 2021-04-06: 966.73 + 2026-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(b) and reg 11(2A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI upper earnings limit + unit: currency-GBP + period: week + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit. + values: + 2015-04-01: 0.02 + 2022-04-01: + value: 0.0325 + metadata: + reference: + - title: Autumn Budget and Spending Review 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf + 2024-04-04: + value: 0.02 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 + href: https://www.legislation.gov.uk/ukpga/1992/4/section/8 + metadata: + label: NI class 1 additional rate + unit: /1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml new file mode 100644 index 0000000000..fa28129bc5 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml @@ -0,0 +1,157 @@ +description: Class 1 employer marginal contribution schedule. +metadata: + label: Class 1 employer marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + unit: currency-GBP + period: week + rate: + values: + 0001-01-01: 0 + metadata: + unit: /1 + - threshold: + description: Secondary threshold for National Insurance. + metadata: + label: NI secondary threshold + period: week + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + unit: currency-GBP + # From 2025-26 the value is the annual £5,000 in SI 2001/1004 reg 11(3A)(b) + # divided by 52 (96.153846 = 5,000 / 52), so that the model's 52-week + # annualisation gives £5,000. This is the convention reg 11(3)(c) and + # 11(2A)(c) use for the primary threshold and upper earnings limit, which the + # model also stores as annual / 52. The rounded weekly figure in reg 10(d) is + # £96, which would annualise to £4,992 (#1967). Before 2025-26 the weekly + # figure times 52 equals the annual one (for example £175 x 52 = £9,100). + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Budget 2024 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model raises the + # threshold each year by September CPI of the previous year, unrounded + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # Autumn Budget 2024 note said the threshold "will be increased in line with + # Consumer Prices Index (CPI)" after its freeze; nothing later sets the rule, + # so this is a modelling assumption, and the file has no uprating. + values: + # £155 was the primary threshold in 2015-16 and 2016-17 (reg 10(c)), not the + # secondary threshold (#2178). + 2015-04-06: + value: 156 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2015 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2015-04-06 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2015 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2015-04-06 + 2016-04-06: + value: 156 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2016 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2016 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2016-04-06 + 2017-04-06: 157 + 2018-04-06: 162 + 2019-04-06: 166 + 2020-04-06: 169 + 2021-04-06: 170 + 2022-04-06: + metadata: + reference: + - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a + title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments + and National Insurance Funds Payments) Regulations 2022(6) + value: 175 + 2024-01-01: 175 + 2025-04-06: + value: 96.153846 + metadata: + reference: + - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.2 + href: https://www.legislation.gov.uk/ukpga/2025/11/section/2 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£5,000 where the earnings period is a year) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11 + 2026-04-06: + value: 96.153846 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) and reg 11(3A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 96.153846 + metadata: + reference: + - title: "HMRC: Changes to the Class 1 National Insurance Contributions Secondary Threshold, the Secondary Class 1 National Insurance contributions rate, and the Employment Allowance from 6 April 2025 (Autumn Budget 2024)" + href: https://www.gov.uk/government/publications/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl + 2028-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + rate: + description: National Insurance contribution rate by employers on earnings above the + Secondary Threshold. + metadata: + label: NI employer rate + propagate_metadata_to_children: true + unit: /1 + # Annual values are the rate at 30 April of each tax year, as for the + # employee main rate (no fiscal_year_blend). For 2022-23 that is the levy + # rate of 15.05%, although earnings paid from 6 November 2022 were charged + # at 13.8%; see #1807 for the choice between sampling and averaging. + values: + 2015-04-01: + value: 0.138 + metadata: + reference: + - title: National Insurance rates and thresholds + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + 2022-04-06: + value: 0.1505 + metadata: + reference: + - title: Health and Social Care Levy Act 2021 s.5(2)(b) + href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted + 2022-11-06: + value: 0.138 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s.2(1) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2/enacted + 2025-01-01: + value: 0.15 + metadata: + reference: + - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.1 + href: https://www.legislation.gov.uk/ukpga/2025/11/section/1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml deleted file mode 100644 index 717968b8a2..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit. -values: - 2015-04-01: 0.02 - 2022-04-01: - value: 0.0325 - metadata: - reference: - - title: Autumn Budget and Spending Review 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf - 2024-04-04: - value: 0.02 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 - href: https://www.legislation.gov.uk/ukpga/1992/4/section/8 -metadata: - label: NI class 1 additional rate - unit: /1 \ No newline at end of file diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml deleted file mode 100644 index f7e10bfa97..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml +++ /dev/null @@ -1,33 +0,0 @@ -description: The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit. -values: - 2015-04-01: 0.12 - 2022-04-06: - value: 0.1325 - metadata: - reference: - - title: Health and Social Care Levy Act 2021 s.5(2)(a)(i) - href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted - - title: Autumn Budget and Spending Review 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf - 2022-11-06: - value: 0.12 - metadata: - reference: - - title: Health and Social Care Levy Act (Repeal) 2022 - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2024-01-01: - value: 0.1 - metadata: - reference: - - title: "Autumn Statement 2023: National Insurance Factsheet" - href: https://www.gov.uk/government/publications/autumn-statement-2023-national-insurance-factsheet/autumn-statement-2023-national-insurance-factsheet - 2024-04-01: - value: 0.08 - metadata: - reference: - - title: National Insurance Contributions (Reduction in Rates) Act 2024 s 1 - href: https://www.legislation.gov.uk/ukpga/2024/5/section/1 -metadata: - label: NI class 1 main rate - unit: /1 - reference: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml deleted file mode 100644 index 55ca38508c..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml +++ /dev/null @@ -1,35 +0,0 @@ -description: National Insurance contribution rate by employers on earnings above the - Secondary Threshold. -metadata: - label: NI employer rate - propagate_metadata_to_children: true - unit: /1 - # Annual values are the rate at 30 April of each tax year, as for the - # employee main rate (no fiscal_year_blend). For 2022-23 that is the levy - # rate of 15.05%, although earnings paid from 6 November 2022 were charged - # at 13.8%; see #1807 for the choice between sampling and averaging. -values: - 2015-04-01: - value: 0.138 - metadata: - reference: - - title: National Insurance rates and thresholds - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - 2022-04-06: - value: 0.1505 - metadata: - reference: - - title: Health and Social Care Levy Act 2021 s.5(2)(b) - href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted - 2022-11-06: - value: 0.138 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s.2(1) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2/enacted - 2025-01-01: - value: 0.15 - metadata: - reference: - - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.1 - href: https://www.legislation.gov.uk/ukpga/2025/11/section/1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml deleted file mode 100644 index 2f934569d5..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml +++ /dev/null @@ -1,89 +0,0 @@ -description: The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance. -# From 6 July 2022 the model stores the annual earnings period threshold -# divided by 52 (12,570 / 52, rounded to 241.73), as SI 2001/1004 reg 11(3)(c) -# does for periods of whole weeks; 52 weeks give £12,569.96. The weekly amount -# in reg 10(c) is £242. -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model keeps the -# threshold at the personal allowance over 52, to the penny, as for 241.73 -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the PT aligned with the personal allowance, which Income -# Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the PT will -# be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI -# 2026/231 para 5.1). That is announced policy, not law; keeping it equal to -# the allowance, round-up included, is a modelling assumption consistent with -# it. The file has no uprating. -values: - 2015-04-06: 155 - 2016-04-06: 155 - 2017-04-06: 157 - 2018-04-06: 162 - 2019-04-06: 166 - 2020-04-06: 183 - 2021-04-06: 184 - 2022-04-06: - value: 190 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2022(6) - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a - 2022-07-01: - value: 241.73 - metadata: - reference: - - title: Spring Statement 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1062486/Spring_Statement_2022_Web_Accessible.pdf - 2026-04-06: - value: 241.73 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(c) and reg 11(3), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 241.73 - metadata: - reference: - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI primary threshold - unit: currency-GBP - period: week - reference: - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml deleted file mode 100644 index d5cac1d7e9..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml +++ /dev/null @@ -1,103 +0,0 @@ -description: Secondary threshold for National Insurance. -metadata: - label: NI secondary threshold - period: week - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions - unit: currency-GBP -# From 2025-26 the value is the annual £5,000 in SI 2001/1004 reg 11(3A)(b) -# divided by 52 (96.153846 = 5,000 / 52), so that the model's 52-week -# annualisation gives £5,000. This is the convention reg 11(3)(c) and -# 11(2A)(c) use for the primary threshold and upper earnings limit, which the -# model also stores as annual / 52. The rounded weekly figure in reg 10(d) is -# £96, which would annualise to £4,992 (#1967). Before 2025-26 the weekly -# figure times 52 equals the annual one (for example £175 x 52 = £9,100). -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Budget 2024 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model raises the -# threshold each year by September CPI of the previous year, unrounded -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# Autumn Budget 2024 note said the threshold "will be increased in line with -# Consumer Prices Index (CPI)" after its freeze; nothing later sets the rule, -# so this is a modelling assumption, and the file has no uprating. -values: - # £155 was the primary threshold in 2015-16 and 2016-17 (reg 10(c)), not the - # secondary threshold (#2178). - 2015-04-06: - value: 156 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2015 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2015-04-06 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2015 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2015-04-06 - 2016-04-06: - value: 156 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2016 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2016 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2016-04-06 - 2017-04-06: 157 - 2018-04-06: 162 - 2019-04-06: 166 - 2020-04-06: 169 - 2021-04-06: 170 - 2022-04-06: - metadata: - reference: - - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments - and National Insurance Funds Payments) Regulations 2022(6) - value: 175 - 2024-01-01: 175 - 2025-04-06: - value: 96.153846 - metadata: - reference: - - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.2 - href: https://www.legislation.gov.uk/ukpga/2025/11/section/2 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£5,000 where the earnings period is a year) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11 - 2026-04-06: - value: 96.153846 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) and reg 11(3A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 96.153846 - metadata: - reference: - - title: "HMRC: Changes to the Class 1 National Insurance Contributions Secondary Threshold, the Secondary Class 1 National Insurance contributions rate, and the Employment Allowance from 6 April 2025 (Autumn Budget 2024)" - href: https://www.gov.uk/government/publications/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl - 2028-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml deleted file mode 100644 index 62ff414c47..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml +++ /dev/null @@ -1,79 +0,0 @@ -description: The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance. -# From 2021-22 the model stores the annual earnings period limit divided by 52 -# (50,270 / 52, rounded to 966.73), as SI 2001/1004 reg 11(2A)(c) does for -# periods of whole weeks; 52 weeks give £50,269.96. The weekly amount in -# reg 10(b) is £967. -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model keeps the -# limit at the higher rate threshold (personal allowance plus basic rate -# limit) over 52, to the penny, as for 966.73 -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# UEL has been aligned with the higher rate threshold since April 2009, and -# for 2026-27 "will remain at a level equivalent to the annual HRT" -# (Explanatory Memorandum to SI 2026/231 paras 5.14-5.15); para 5.1 says it -# will be uprated by September CPI after 2030-31. Income Tax Act 2007 ss21 and -# 57 index the higher rate threshold by September CPI from 2031-32. Keeping -# the UEL equal to it, round-ups included, is a modelling assumption -# consistent with that announced policy, not law. The file has no uprating. -values: - 2015-04-06: 815 - 2016-04-06: 827 - 2017-04-06: 866 - 2018-04-06: 892 - 2019-04-06: 962 - 2020-04-06: 962 - 2021-04-06: 966.73 - 2026-04-06: - value: 966.73 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(b) and reg 11(2A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 966.73 - metadata: - reference: - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI upper earnings limit - unit: currency-GBP - period: week - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml new file mode 100644 index 0000000000..92e21b5a6c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml @@ -0,0 +1,328 @@ +description: Class 4 marginal contribution schedule. +metadata: + label: Class 4 marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + unit: currency-GBP + period: year + rate: + values: + 0001-01-01: 0 + metadata: + unit: /1 + - threshold: + description: The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate. + # Legal status: £12,570 is the standing figure in SSCBA 1992 s.15(3)(a), which + # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to + # keeping it until April 2031. From 2031-32 (the tax year after the last value + # below) the model keeps the limit equal to the personal allowance + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the LPL aligned with the personal allowance, which + # Income Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the + # LPL will be uprated by September CPI after 2030-31 (Explanatory Memorandum + # to SI 2026/231 para 5.1). That is announced policy, not law; keeping it + # equal to the allowance, round-up included, is a modelling assumption + # consistent with it. The file has no uprating. + values: + 2015-04-06: + value: 8_060 + metadata: + reference: + - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(a) + href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made + - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2016, unchanged for 2016-17) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2016-04-06 + 2017-04-06: + value: 8_164 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made + 2018-04-06: + value: 8_424 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made + 2019-04-06: + value: 8_632 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made + 2020-04-06: + value: 9_500 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2020 reg 5 + href: https://www.legislation.gov.uk/uksi/2020/299/regulation/5/made + 2021-04-06: + value: 9_568 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(1) + href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made + # SI 2022/232 reg 5 first set £9,880 for 2022-23; the 2022 Act replaced it + # with £11,908 for the whole year. + 2022-04-06: + value: 11_908 + metadata: + reference: + - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(1) and (2) + href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted + 2023-04-06: + value: 12_570 + metadata: + reference: + - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(3) and (4) + href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + 2026-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + 2027-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI lower profits limit + unit: currency-GBP + period: year + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits. + values: + 2015-04-06: + value: 0.09 + metadata: + reference: + - title: National Insurance Contributions Act 2011 s 2(1)(a) (main Class 4 percentage of 9% from 6 April 2011) + href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2015) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 + # Class 4 is charged on the year's profits, so 2022-23 has one rate for the + # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced + # the 10.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. + 2022-04-06: + value: 0.0973 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(a) (main Class 4 percentage of 9.73% for the tax year 2022-23) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2023-04-06: + value: 0.09 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + 2024-04-06: + value: 0.06 + metadata: + reference: + - title: National Insurance Contributions (Reduction in Rates) Act 2024 ss 1(3)(a) and 3(1) + href: https://www.legislation.gov.uk/ukpga/2024/5/section/1/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2024) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2024-04-06 + metadata: + label: NI class 4 main rate + unit: /1 + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + - threshold: + description: The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate. + # Legal status: £50,270 is the standing figure in SSCBA 1992 s.15(3)(b), which + # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to + # keeping it until April 2031. From 2031-32 (the tax year after the last value + # below) the model keeps the limit equal to the higher rate threshold + # (personal allowance plus basic rate limit) + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the UPL in line with the higher rate threshold, which + # Income Tax Act 2007 ss21 and 57 index by September CPI from 2031-32, and + # says the UPL will be uprated by September CPI after 2030-31 (Explanatory + # Memorandum to SI 2026/231 paras 5.1 and 5.25). That is announced policy, not + # law; keeping it equal to the higher rate threshold, round-ups included, is a + # modelling assumption consistent with it. The file has no uprating. + values: + 2015-04-06: + value: 42_385 + metadata: + reference: + - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(b) + href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made + 2016-04-06: + value: 43_000 + metadata: + reference: + - title: The Social Security (Contributions) (Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2016 reg 2 + href: https://www.legislation.gov.uk/uksi/2016/343/regulation/2/made + 2017-04-06: + value: 45_000 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made + 2018-04-06: + value: 46_350 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made + 2019-04-06: + value: 50_000 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made + - title: Social Security Contributions and Benefits Act 1992 s 15(3) (as at 6 April 2020, unchanged for 2020-21) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2020-04-06 + 2021-04-06: + value: 50_270 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(2) + href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made + 2026-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + 2027-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI upper profits limit + unit: currency-GBP + period: year + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits. + values: + 2015-04-06: + value: 0.02 + metadata: + reference: + - title: National Insurance Contributions Act 2011 s 2(1)(b) (additional Class 4 percentage of 2% from 6 April 2011) + href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2015) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 + # Class 4 is charged on the year's profits, so 2022-23 has one rate for the + # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced + # the 3.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. + 2022-04-06: + value: 0.0273 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(b) (additional Class 4 percentage of 2.73% for the tax year 2022-23) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2023-04-06: + value: 0.02 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + metadata: + label: NI class 4 additional rate + unit: /1 + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml deleted file mode 100644 index 81d3c7d5c6..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml +++ /dev/null @@ -1,33 +0,0 @@ -description: The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits. -values: - 2015-04-06: - value: 0.02 - metadata: - reference: - - title: National Insurance Contributions Act 2011 s 2(1)(b) (additional Class 4 percentage of 2% from 6 April 2011) - href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2015) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 - # Class 4 is charged on the year's profits, so 2022-23 has one rate for the - # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced - # the 3.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. - 2022-04-06: - value: 0.0273 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(b) (additional Class 4 percentage of 2.73% for the tax year 2022-23) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2023-04-06: - value: 0.02 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 -metadata: - label: NI class 4 additional rate - unit: /1 - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml deleted file mode 100644 index 5f3dab92c3..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml +++ /dev/null @@ -1,41 +0,0 @@ -description: The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits. -values: - 2015-04-06: - value: 0.09 - metadata: - reference: - - title: National Insurance Contributions Act 2011 s 2(1)(a) (main Class 4 percentage of 9% from 6 April 2011) - href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2015) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 - # Class 4 is charged on the year's profits, so 2022-23 has one rate for the - # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced - # the 10.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. - 2022-04-06: - value: 0.0973 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(a) (main Class 4 percentage of 9.73% for the tax year 2022-23) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2023-04-06: - value: 0.09 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 - 2024-04-06: - value: 0.06 - metadata: - reference: - - title: National Insurance Contributions (Reduction in Rates) Act 2024 ss 1(3)(a) and 3(1) - href: https://www.legislation.gov.uk/ukpga/2024/5/section/1/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2024) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2024-04-06 -metadata: - label: NI class 4 main rate - unit: /1 - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml deleted file mode 100644 index ffb02e530a..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml +++ /dev/null @@ -1,124 +0,0 @@ -description: The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate. -# Legal status: £12,570 is the standing figure in SSCBA 1992 s.15(3)(a), which -# stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. From 2031-32 (the tax year after the last value -# below) the model keeps the limit equal to the personal allowance -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the LPL aligned with the personal allowance, which -# Income Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the -# LPL will be uprated by September CPI after 2030-31 (Explanatory Memorandum -# to SI 2026/231 para 5.1). That is announced policy, not law; keeping it -# equal to the allowance, round-up included, is a modelling assumption -# consistent with it. The file has no uprating. -values: - 2015-04-06: - value: 8_060 - metadata: - reference: - - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(a) - href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made - - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2016, unchanged for 2016-17) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2016-04-06 - 2017-04-06: - value: 8_164 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made - 2018-04-06: - value: 8_424 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made - 2019-04-06: - value: 8_632 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made - 2020-04-06: - value: 9_500 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2020 reg 5 - href: https://www.legislation.gov.uk/uksi/2020/299/regulation/5/made - 2021-04-06: - value: 9_568 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(1) - href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made - # SI 2022/232 reg 5 first set £9,880 for 2022-23; the 2022 Act replaced it - # with £11,908 for the whole year. - 2022-04-06: - value: 11_908 - metadata: - reference: - - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(1) and (2) - href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted - 2023-04-06: - value: 12_570 - metadata: - reference: - - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(3) and (4) - href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 - 2026-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - 2027-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI lower profits limit - unit: currency-GBP - period: year - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml deleted file mode 100644 index 74e475204d..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml +++ /dev/null @@ -1,109 +0,0 @@ -description: The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate. -# Legal status: £50,270 is the standing figure in SSCBA 1992 s.15(3)(b), which -# stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. From 2031-32 (the tax year after the last value -# below) the model keeps the limit equal to the higher rate threshold -# (personal allowance plus basic rate limit) -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the UPL in line with the higher rate threshold, which -# Income Tax Act 2007 ss21 and 57 index by September CPI from 2031-32, and -# says the UPL will be uprated by September CPI after 2030-31 (Explanatory -# Memorandum to SI 2026/231 paras 5.1 and 5.25). That is announced policy, not -# law; keeping it equal to the higher rate threshold, round-ups included, is a -# modelling assumption consistent with it. The file has no uprating. -values: - 2015-04-06: - value: 42_385 - metadata: - reference: - - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(b) - href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made - 2016-04-06: - value: 43_000 - metadata: - reference: - - title: The Social Security (Contributions) (Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2016 reg 2 - href: https://www.legislation.gov.uk/uksi/2016/343/regulation/2/made - 2017-04-06: - value: 45_000 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made - 2018-04-06: - value: 46_350 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made - 2019-04-06: - value: 50_000 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made - - title: Social Security Contributions and Benefits Act 1992 s 15(3) (as at 6 April 2020, unchanged for 2020-21) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2020-04-06 - 2021-04-06: - value: 50_270 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(2) - href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made - 2026-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - 2027-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI upper profits limit - unit: currency-GBP - period: year - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py b/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py index d88f3a7616..c2108e39c5 100644 --- a/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py +++ b/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py @@ -10,6 +10,7 @@ """ from policyengine_core.parameters import ParameterNode +from policyengine_uk.utils.ni_parameter_scales import canonicalize_ni_parameter_path _LEGACY_LSR_PATH = "gov.simulation.labor_supply_responses" @@ -35,7 +36,7 @@ def canonicalize_parameter_changes(changes: dict) -> dict: """ canonical = {} for path, value in changes.items(): - name = canonicalize_lsr_parameter_path(path) + name = canonicalize_ni_parameter_path(canonicalize_lsr_parameter_path(path)) if name not in canonical: canonical[name] = value elif isinstance(canonical[name], dict) and isinstance(value, dict): diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index cfd1f67801..b4a2c58ad4 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -79,6 +79,15 @@ convert_to_fiscal_year_parameters, ) +from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_descendants, + canonical_ni_processing, + validate_ni_scale_compatibility, + canonical_ni_uprating_references, + canonicalize_ni_parameter_path, +) + logger = logging.getLogger(__name__) # Module constants @@ -176,6 +185,7 @@ def process_parameter_tree( written just after the step that generates it (see ``_add_generated_parameters``). """ + validate_ni_scale_compatibility(parameters) parameters = _add_generated_parameters(parameters, generated) # Create baseline parameters for reform comparisons @@ -188,8 +198,10 @@ def process_parameter_tree( parameters.add_child("baseline", baseline) # Apply general parameter operations - parameters = propagate_parameter_metadata(parameters) - parameters = uprate_parameters(parameters) + with canonical_ni_processing(parameters): + propagate_parameter_metadata(parameters) + with canonical_ni_uprating_references(parameters): + uprate_parameters(parameters) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the # gov tree, before annualisation, and the trees outside it (household), @@ -240,8 +252,9 @@ def neutralize_variable(self, variable_name: str) -> None: def reset_parameter_caches(self): """Reset all caches in the tax-benefit system.""" + validate_ni_scale_compatibility(self.parameters) self._parameters_at_instant_cache = {} - for parameter in self.parameters.get_descendants(): + for parameter in canonical_descendants(self.parameters): parameter._at_instant_cache = {} self.parameters._at_instant_cache = {} @@ -579,6 +592,8 @@ def _leaf_paths(node: ParameterNode, prefix: str = "") -> List[str]: paths = [] for key, child in node.children.items(): path = f"{prefix}.{key}" if prefix else key + if isinstance(child, LegacyNIParameter): + continue if isinstance(child, Parameter): paths.append(path) elif isinstance(child, ParameterNode): @@ -603,7 +618,9 @@ def generated_parameter_defines() -> Callable[[str], bool]: _generated_paths_cache = frozenset( path for path in _leaf_paths(generated) - if not statutory(path) and canonicalize_lsr_parameter_path(path) == path + if not statutory(path) + and canonicalize_ni_parameter_path(canonicalize_lsr_parameter_path(path)) + == path ) return _generated_paths_cache.__contains__ diff --git a/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json b/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json new file mode 100644 index 0000000000..7f556e97e8 --- /dev/null +++ b/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json @@ -0,0 +1,266 @@ +{ + "api_commit": "71dba65b3780f0bb4699d4dcbfe179e5f1506c78", + "extraction": "Exact source-extracted pinned API method and exact helper against unmodified production before migration. Not the installed/full API application.", + "parameters": { + "gov.hmrc.national_insurance.class_1.rates.employee.additional": { + "description": "The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit.", + "economy": true, + "household": true, + "label": "NI class 1 additional rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employee.additional", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.02, + "2015-04-01": 0.02, + "2022-04-01": 0.0325, + "2024-04-04": 0.02 + } + }, + "gov.hmrc.national_insurance.class_1.rates.employee.main": { + "description": "The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit.", + "economy": true, + "household": true, + "label": "NI class 1 main rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employee.main", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.12, + "2015-04-01": 0.12, + "2022-04-06": 0.1325, + "2022-11-06": 0.12, + "2024-01-01": 0.1, + "2024-04-01": 0.08 + } + }, + "gov.hmrc.national_insurance.class_1.rates.employer": { + "description": "National Insurance contribution rate by employers on earnings above the Secondary Threshold.", + "economy": true, + "household": true, + "label": "NI employer rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employer", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.138, + "2015-04-01": 0.138, + "2022-04-06": 0.1505, + "2022-11-06": 0.138, + "2025-01-01": 0.15 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold": { + "description": "The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance.", + "economy": true, + "household": true, + "label": "NI primary threshold", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 155, + "2015-04-06": 155, + "2016-04-06": 155, + "2017-04-06": 157, + "2018-04-06": 162, + "2019-04-06": 166, + "2020-04-06": 183, + "2021-04-06": 184, + "2022-04-06": 190, + "2022-07-01": 241.73, + "2026-04-06": 241.73, + "2027-04-06": 241.73, + "2028-04-06": 241.73, + "2029-04-06": 241.73, + "2030-04-06": 241.73, + "2031-01-01": 246.73, + "2032-01-01": 251.73, + "2033-01-01": 256.92, + "2034-01-01": 262.12, + "2035-01-01": 267.5, + "2036-01-01": 272.88, + "2037-01-01": 278.46, + "2038-01-01": 284.04, + "2039-01-01": 289.81 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold": { + "description": "Secondary threshold for National Insurance.", + "economy": true, + "household": true, + "label": "NI secondary threshold", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 156, + "2015-04-06": 156, + "2016-04-06": 156, + "2017-04-06": 157, + "2018-04-06": 162, + "2019-04-06": 166, + "2020-04-06": 169, + "2021-04-06": 170, + "2022-04-06": 175, + "2024-01-01": 175, + "2025-04-06": 96.153846, + "2026-04-06": 96.153846, + "2027-04-06": 96.153846, + "2028-04-06": 96.153846, + "2029-04-06": 96.153846, + "2030-04-06": 96.153846, + "2031-01-01": 98.07692292, + "2032-01-01": 100.0384613784, + "2033-01-01": 102.039230605968, + "2034-01-01": 104.08001521808735, + "2035-01-01": 106.16161552244911, + "2036-01-01": 108.2848478328981, + "2037-01-01": 110.45054478955605, + "2038-01-01": 112.65955568534717, + "2039-01-01": 114.91274679905412 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit": { + "description": "The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance.", + "economy": true, + "household": true, + "label": "NI upper earnings limit", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 815, + "2015-04-06": 815, + "2016-04-06": 827, + "2017-04-06": 866, + "2018-04-06": 892, + "2019-04-06": 962, + "2020-04-06": 962, + "2021-04-06": 966.73, + "2026-04-06": 966.73, + "2027-04-06": 966.73, + "2028-04-06": 966.73, + "2029-04-06": 966.73, + "2030-04-06": 966.73, + "2031-01-01": 987.12, + "2032-01-01": 1007.5, + "2033-01-01": 1028.08, + "2034-01-01": 1050.58, + "2035-01-01": 1073.27, + "2036-01-01": 1095.96, + "2037-01-01": 1118.85, + "2038-01-01": 1141.73, + "2039-01-01": 1164.81 + } + }, + "gov.hmrc.national_insurance.class_4.rates.additional": { + "description": "The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits.", + "economy": true, + "household": true, + "label": "NI class 4 additional rate", + "parameter": "gov.hmrc.national_insurance.class_4.rates.additional", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.02, + "2015-04-06": 0.02, + "2022-04-06": 0.0273, + "2023-04-06": 0.02 + } + }, + "gov.hmrc.national_insurance.class_4.rates.main": { + "description": "The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits.", + "economy": true, + "household": true, + "label": "NI class 4 main rate", + "parameter": "gov.hmrc.national_insurance.class_4.rates.main", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.09, + "2015-04-06": 0.09, + "2022-04-06": 0.0973, + "2023-04-06": 0.09, + "2024-04-06": 0.06 + } + }, + "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit": { + "description": "The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate.", + "economy": true, + "household": true, + "label": "NI lower profits limit", + "parameter": "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", + "period": "year", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 8060, + "2015-04-06": 8060, + "2017-04-06": 8164, + "2018-04-06": 8424, + "2019-04-06": 8632, + "2020-04-06": 9500, + "2021-04-06": 9568, + "2022-04-06": 11908, + "2023-04-06": 12570, + "2026-04-06": 12570, + "2027-04-06": 12570, + "2028-04-06": 12570, + "2029-04-06": 12570, + "2030-04-06": 12570, + "2031-01-01": 12830.0, + "2032-01-01": 13090.0, + "2033-01-01": 13360.0, + "2034-01-01": 13630.0, + "2035-01-01": 13910.0, + "2036-01-01": 14190.0, + "2037-01-01": 14480.0, + "2038-01-01": 14770.0, + "2039-01-01": 15070.0 + } + }, + "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit": { + "description": "The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate.", + "economy": true, + "household": true, + "label": "NI upper profits limit", + "parameter": "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", + "period": "year", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 42385, + "2015-04-06": 42385, + "2016-04-06": 43000, + "2017-04-06": 45000, + "2018-04-06": 46350, + "2019-04-06": 50000, + "2021-04-06": 50270, + "2026-04-06": 50270, + "2027-04-06": 50270, + "2028-04-06": 50270, + "2029-04-06": 50270, + "2030-04-06": 50270, + "2031-01-01": 51330.0, + "2032-01-01": 52390.0, + "2033-01-01": 53460.0, + "2034-01-01": 54630.0, + "2035-01-01": 55810.0, + "2036-01-01": 56990.0, + "2037-01-01": 58180.0, + "2038-01-01": 59370.0, + "2039-01-01": 60570.0 + } + } + }, + "source_base": "e1601dbe4feb15e71d0d59b7a57f9d860741c068" +} diff --git a/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py b/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py new file mode 100644 index 0000000000..77b9c3af9f --- /dev/null +++ b/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py @@ -0,0 +1,179 @@ +"""Source-extracted API contract, not an installed/full API execution. + +Country.build_parameters and get_safe_json are copied without body changes from +PolicyEngine/policyengine-api commit 71dba65b3780f0bb4699d4dcbfe179e5f1506c78. +country.py SHA256 cfbb900691d45bd95e05b08e53bf72708ac75ee82d1647c04ff09240a92e61bb; +utils/json.py SHA256 acef75a37b1d90cc52c0bda57db47ffd2348eaa400d4674d244c8090b47ddc61. +https://github.com/PolicyEngine/policyengine-api/blob/71dba65b3780f0bb4699d4dcbfe179e5f1506c78/policyengine_api/country.py#L371 +https://github.com/PolicyEngine/policyengine-api/blob/71dba65b3780f0bb4699d4dcbfe179e5f1506c78/policyengine_api/utils/json.py#L34 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +if TYPE_CHECKING: + from policyengine_api.json_types import JSONValue + +from policyengine_core.parameters.operations.get_parameter import get_parameter +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform +from policyengine_core.parameters import ( + Parameter, + ParameterNode, + ParameterScale, + ParameterScaleBracket, +) + + +def get_safe_json(value: object) -> JSONValue: + # This function is used when constructing metadata, + # which will be consumed by JS, hence Infinity, not .inf + if isinstance(value, (int, float)): + if value == np.inf: + return "Infinity" + if value == -np.inf: + return "-Infinity" + return value + if isinstance(value, str): + return value + if isinstance(value, dict): + return {k: get_safe_json(v) for k, v in value.items()} + if isinstance(value, list): + return [get_safe_json(v) for v in value] + return None + + +def build_parameters(self) -> dict: + parameters = self.tax_benefit_system.parameters + parameter_data = {} + for parameter in parameters.get_descendants(): + if "gov" != parameter.name[:3]: + continue + end_name = parameter.name.split(".")[-1] + if isinstance(parameter, ParameterScale): + parameter.propagate_units() + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + } + elif isinstance(parameter, ParameterScaleBracket): + bracket_index = int(parameter.name[parameter.name.index("[") + 1 : -1]) + # Set the label to 'first bracket' for the first bracket, 'second bracket' for the second, etc. + bracket_label = f"bracket {bracket_index + 1}" + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", bracket_label), + } + elif isinstance(parameter, Parameter): + parameter_data[parameter.name] = { + "type": "parameter", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + "unit": parameter.metadata.get("unit"), + "period": parameter.metadata.get("period"), + "values": { + value_at_instant.instant_str: get_safe_json(value_at_instant.value) + for value_at_instant in parameter.values_list + }, + "economy": parameter.metadata.get("economy", True), + "household": parameter.metadata.get("household", True), + } + elif isinstance(parameters, ParameterNode): + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + "economy": parameter.metadata.get("economy", True), + "household": parameter.metadata.get("household", True), + } + return parameter_data + + +def _normalize_reform_values( + self, + reform: dict, + system: TaxBenefitSystem, +) -> dict: + normalized_reform = {} + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(system.parameters, parameter_name) + normalized_reform[parameter_name] = {} + for time_period, value in parameter_updates.items(): + node_type = type(parameter.values_list[-1].value) + if node_type is int: + node_type = float + try: + value = float(value) + except Exception: + pass + normalized_reform[parameter_name][time_period] = node_type(value) + return normalized_reform + + +def _apply_reform_to_system( + self, + system: TaxBenefitSystem, + reform: dict, +) -> None: + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(system.parameters, parameter_name) + for time_period, value in parameter_updates.items(): + start_instant, end_instant = time_period.split(".") + parameter.update( + start=instant(start_instant), + stop=instant(end_instant), + value=value, + ) + + +def create_policy_reform(policy_data: dict) -> dict: + """ + Create a policy reform. + + Args: + policy_data (dict): The policy data. + + Returns: + dict: The reform. + """ + + def modify_parameters(parameters: ParameterNode) -> ParameterNode: + for path, values in policy_data.items(): + node = parameters + for step in path.split("."): + if "[" in step: + step, index = step.split("[") + index = int(index[:-1]) + node = node.children[step].brackets[index] + else: + node = node.children[step] + for period, value in values.items(): + start, end = period.split(".") + node_type = type(node.values_list[-1].value) + if node_type is int: + node_type = float # '0' is of type int by default, but usually we want to cast to float. + if node.values_list[-1].value is None: + node_type = float + node.update( + start=instant(start), + stop=instant(end), + value=node_type(value), + ) + + return parameters + + class reform(Reform): + def apply(self) -> None: + self.modify_parameters(modify_parameters) + + return reform diff --git a/policyengine_uk/tests/test_ni_health_and_social_care_levy.py b/policyengine_uk/tests/test_ni_health_and_social_care_levy.py index d0cbd4d53f..e240128b47 100644 --- a/policyengine_uk/tests/test_ni_health_and_social_care_levy.py +++ b/policyengine_uk/tests/test_ni_health_and_social_care_levy.py @@ -53,10 +53,9 @@ / "hmrc" / "national_insurance" / "class_1" - / "rates" ) -EMPLOYER_FILE = RATES / "employer.yaml" -EMPLOYEE_MAIN_FILE = RATES / "employee" / "main.yaml" +EMPLOYER_FILE = RATES / "employer_scale.yaml" +EMPLOYEE_MAIN_FILE = RATES / "employee_scale.yaml" LEVY_START = datetime.date(2022, 4, 6) # HSCL Act 2021 s.5(1): tax year 2022-23 LEVY_END = datetime.date(2022, 11, 6) # Repeal Act 2022 s.2(1) @@ -72,12 +71,12 @@ @pytest.fixture(scope="module") def employer(): - return load_parameter_file(str(EMPLOYER_FILE), "employer") + return load_parameter_file(str(EMPLOYER_FILE), "employer").brackets[1].rate @pytest.fixture(scope="module") def employee_main(): - return load_parameter_file(str(EMPLOYEE_MAIN_FILE), "main") + return load_parameter_file(str(EMPLOYEE_MAIN_FILE), "main").brackets[1].rate @pytest.fixture(scope="module") diff --git a/policyengine_uk/tests/test_ni_metadata_compat.py b/policyengine_uk/tests/test_ni_metadata_compat.py new file mode 100644 index 0000000000..62f3933555 --- /dev/null +++ b/policyengine_uk/tests/test_ni_metadata_compat.py @@ -0,0 +1,86 @@ +"""Pinned source-extracted API contracts; no installed API or service calls.""" + +from types import SimpleNamespace +from pathlib import Path +from policyengine_core.parameters.operations.get_parameter import get_parameter +import json +import pytest + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.tests.fixtures.ni_metadata_api_contract import ( + build_parameters, + _normalize_reform_values, + _apply_reform_to_system, + create_policy_reform, + get_safe_json, +) + +OLD = "gov.hmrc.national_insurance.class_1.rates.employee.main" +NEW = "gov.hmrc.national_insurance.class_1.employee_scale[1].rate" + + +@pytest.mark.parametrize("path", [OLD, NEW]) +def test_actual_core_get_parameter_api_normalization_and_update(path, record_property): + original = CountryTaxBenefitSystem() + system = original.clone() + data = {path: {"2026-01-01.2026-12-31": "0.16"}} + normalized = _normalize_reform_values(SimpleNamespace(), data, system) + assert normalized[path]["2026-01-01.2026-12-31"] == 0.16 + build_parameters(SimpleNamespace(tax_benefit_system=system)) + _apply_reform_to_system(SimpleNamespace(), system, normalized) + actual = build_parameters(SimpleNamespace(tax_benefit_system=system)) + # Exact immutable-B control reproduces baseline-name overwrite in this + # synthetic reserialization: construction-cached Country metadata is not + # a live changed-system export. Preserve that whole old record, rather + # than introducing an unrelated baseline/API lifecycle change. + # Base-control result SHA25645bc659d1915a86afb6340ef276e535588783f88a46218b993f4cb3c97a14c02. + expected = json.loads( + ( + Path(__file__).parent / "fixtures/ni_legacy_parameter_metadata.json" + ).read_text() + )["parameters"][OLD] + assert actual[OLD] == expected + assert actual[NEW] == {**expected, "parameter": NEW} + alias = get_parameter(system.parameters, OLD) + canonical = get_parameter(system.parameters, NEW) + assert alias.metadata is canonical.metadata + assert alias.values_list is canonical.values_list + assert alias("2026") == canonical("2026") == 0.16 + assert any( + entry.instant_str == "2026-01-01" and entry.value == 0.16 + for entry in alias.values_list + ) + assert system.parameters.baseline.get_child(OLD)("2026") == 0.08 + record_property( + "source_extracted_metadata_contract", + json.dumps( + { + "update_path": path, + "old_complete_record": actual[OLD], + "canonical_complete_record": actual[NEW], + "live_root_value": alias("2026"), + "baseline_value": system.parameters.baseline.get_child(OLD)("2026"), + "same_values_list": alias.values_list is canonical.values_list, + "same_metadata": alias.metadata is canonical.metadata, + }, + sort_keys=True, + ), + ) + assert original.parameters.get_child(OLD)("2026") == 0.08 + json.dumps(actual, allow_nan=False) + + +@pytest.mark.parametrize("path", [OLD, NEW]) +def test_actual_legacy_api_manual_children_reform_route(path): + original = CountryTaxBenefitSystem() + reform = create_policy_reform({path: {"2026-01-01.2026-12-31": 0.14}}) + updated = reform(original) + assert updated.parameters.get_child(OLD)("2026") == 0.14 + assert updated.parameters.get_child(NEW)("2026") == 0.14 + assert original.parameters.get_child(OLD)("2026") == 0.08 + + +def test_exact_pinned_json_helper_handles_nonfinite_nested_values(): + assert get_safe_json({"value": [float("inf"), float("-inf"), None, 0.08]}) == { + "value": ["Infinity", "-Infinity", None, 0.08] + } diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py new file mode 100644 index 0000000000..2eba48fffc --- /dev/null +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -0,0 +1,409 @@ +"""Public NI scale and legacy leaf contracts, specified before migration.""" + +import json +from pathlib import Path + +import pytest +from policyengine_core.parameters import Parameter, ParameterScale + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.tests.fixtures.ni_metadata_api_contract import build_parameters +from types import SimpleNamespace + +PREFIX = "gov.hmrc.national_insurance." +LEAVES = { + "class_1.rates.employee.main": "class_1.employee_scale[1].rate", + "class_1.rates.employee.additional": "class_1.employee_scale[2].rate", + "class_1.thresholds.primary_threshold": "class_1.employee_scale[1].threshold", + "class_1.thresholds.upper_earnings_limit": "class_1.employee_scale[2].threshold", + "class_1.rates.employer": "class_1.employer_scale[1].rate", + "class_1.thresholds.secondary_threshold": "class_1.employer_scale[1].threshold", + "class_4.rates.main": "class_4.contribution_scale[1].rate", + "class_4.rates.additional": "class_4.contribution_scale[2].rate", + "class_4.thresholds.lower_profits_limit": "class_4.contribution_scale[1].threshold", + "class_4.thresholds.upper_profits_limit": "class_4.contribution_scale[2].threshold", +} + + +def test_canonical_marginal_schedules_are_discoverable(): + parameters = CountryTaxBenefitSystem().parameters + for path in [ + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", + ]: + assert isinstance(parameters.get_child(PREFIX + path), ParameterScale) + + +@pytest.mark.parametrize("legacy,canonical", LEAVES.items()) +def test_ten_legacy_scalar_paths_resolve_to_canonical_leaves(legacy, canonical): + parameters = CountryTaxBenefitSystem().parameters + assert parameters.get_child(PREFIX + legacy) is parameters.get_child( + PREFIX + canonical + ) + + +def test_legacy_callable_attribute_updates_reach_canonical_leaves(): + system = CountryTaxBenefitSystem() + tree = system.parameters.clone() + tree("2026") # Warm canonical and sibling parent reads. + old_parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + old_parent("2026") + old_parent.main.update(period="2026", value=0.19) + assert tree.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") == 0.19 + assert tree("2026").gov.hmrc.national_insurance.class_1.rates.employee.main == 0.19 + assert old_parent("2026").main == 0.19 + assert ( + system.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + + +def test_live_parameter_metadata_preserves_legacy_scalar_records(): + expected = json.loads( + ( + Path(__file__).parent / "fixtures/ni_legacy_parameter_metadata.json" + ).read_text() + )["parameters"] + actual = build_parameters( + SimpleNamespace(tax_benefit_system=CountryTaxBenefitSystem()) + ) + assert {name: actual[name] for name in expected} == expected + json.dumps(actual, allow_nan=False) + + +def test_clone_and_baseline_views_resolve_within_their_own_tree(): + source = CountryTaxBenefitSystem().parameters + clone = source.clone() + source_value = source.gov.hmrc.national_insurance.class_1.rates.employee.main( + "2026" + ) + clone.gov.hmrc.national_insurance.class_1.rates.employee.main.update( + period="2026", value=0.17 + ) + assert clone.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") == 0.17 + assert ( + source.gov.hmrc.national_insurance.class_1.rates.employee.main("2026") + == source_value + ) + assert ( + clone.baseline.gov.hmrc.national_insurance.class_1.rates.employee.main("2026") + == source_value + ) + + +def test_canonical_leaves_have_one_authoritative_registered_occurrence(): + tree = CountryTaxBenefitSystem().parameters + canonical_names = [PREFIX + name for name in LEAVES.values()] + descendants = list(tree.gov.get_descendants()) + for name in canonical_names: + canonical = tree.get_child(name) + assert isinstance(canonical, Parameter) + assert sum(item is canonical for item in descendants) == 1 + + +def test_standalone_legacy_clone_is_independent(): + tree = CountryTaxBenefitSystem().parameters + leaf = tree.gov.hmrc.national_insurance.class_1.rates.employee.main + independent = leaf.clone() + independent.update(period="2026", value=0.18) + assert leaf("2026") == 0.08 + assert independent("2026") == 0.18 + + +def test_processing_iteration_and_error_restoration_preserve_real_identity(): + from policyengine_core.parameters.operations.get_parameter import get_parameter + from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_ni_processing, + ) + + tree = CountryTaxBenefitSystem().parameters.clone() + legacy_parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + original_children = legacy_parent.children + legacy = original_children["main"] + canonical = tree.get_child(PREFIX + LEAVES["class_1.rates.employee.main"]) + canonical_parent = canonical.parent + before = build_parameters( + SimpleNamespace(tax_benefit_system=SimpleNamespace(parameters=tree)) + ) + with pytest.raises(RuntimeError, match="deliberate"): + with canonical_ni_processing(tree) as same: + assert same is tree + assert canonical.parent is canonical_parent + assert get_parameter(tree, PREFIX + "class_1.rates.employee.main") is legacy + assert not any( + isinstance(item, LegacyNIParameter) for item in tree.get_descendants() + ) + assert sum(item is canonical for item in tree.get_descendants()) == 1 + raise RuntimeError("deliberate failure") + assert legacy_parent.children is original_children + assert canonical.parent is canonical_parent + assert ( + build_parameters( + SimpleNamespace(tax_benefit_system=SimpleNamespace(parameters=tree)) + ) + == before + ) + + +def test_core_uprating_orders_legacy_dependencies_without_double_processing(): + from policyengine_core.parameters.operations.uprate_parameters import ( + uprate_parameters, + ) + from policyengine_uk.utils.ni_parameter_scales import ( + canonical_ni_processing, + canonical_ni_uprating_references, + ) + + tree = CountryTaxBenefitSystem().parameters.clone() + primary = tree.get_child(PREFIX + "class_1.thresholds.primary_threshold") + secondary = tree.get_child(PREFIX + "class_1.thresholds.secondary_threshold") + rate = tree.get_child(PREFIX + "class_1.rates.employee.main") + primary.update(start="2041-01-01", value=100) + primary.update(start="2042-01-01", value=200) + secondary.update(start="2041-01-01", value=50) + rate.update(start="2041-01-01", value=0.1) + secondary.metadata["uprating"] = PREFIX + "class_1.thresholds.primary_threshold" + rate.metadata["uprating"] = { + "parameter": PREFIX + "class_1.thresholds.secondary_threshold" + } + old_metadata = rate.metadata["uprating"] + with canonical_ni_processing(tree): + with canonical_ni_uprating_references(tree): + uprate_parameters(tree) + assert secondary("2042") == 100 + assert rate("2042") == 0.2 + assert rate.metadata["uprating"] is old_metadata + assert ( + tree.baseline.get_child(PREFIX + "class_1.rates.employee.main")("2042") == 0.08 + ) + + +def test_recursive_core_metadata_propagation_visits_each_authoritative_leaf_once(): + from policyengine_core.parameters.operations.propagate_parameter_metadata import ( + propagate_parameter_metadata, + ) + from policyengine_uk.utils.ni_parameter_scales import canonical_ni_processing + + tree = CountryTaxBenefitSystem().parameters.clone() + ni = tree.gov.hmrc.national_insurance + ni.metadata.update(propagate_metadata_to_children=True, scope_probe="owned-tree") + with canonical_ni_processing(tree): + propagate_parameter_metadata(tree) + for canonical in LEAVES.values(): + assert ( + tree.get_child(PREFIX + canonical).metadata["scope_probe"] == "owned-tree" + ) + assert ( + "scope_probe" + not in CountryTaxBenefitSystem().parameters.gov.hmrc.national_insurance.metadata + ) + + +@pytest.mark.parametrize( + "target", + ["legacy_leaf", "canonical_scale", "canonical_deleted", "canonical_leaf_deleted"], +) +def test_conflicting_structural_replacement_is_rejected_without_mutating_source(target): + from policyengine_core.parameters import ParameterNode, ParameterScale + + original = CountryTaxBenefitSystem() + changed = original.clone() + changed.reset_parameters() + ni = changed.parameters.gov.hmrc.national_insurance + if target == "legacy_leaf": + parent = ni.class_1.rates.employee + replacement = Parameter(parent.main.name, {"values": {"2026-01-01": 0.7}}) + parent.children["main"] = replacement + parent.main = replacement + replacement.parent = parent + elif target == "canonical_scale": + parent = ni.class_1 + replacement = ParameterScale( + parent.employee_scale.name, + { + "brackets": [ + {"threshold": {"2026-01-01": 0}, "rate": {"2026-01-01": 0.7}} + ] + }, + file_path=None, + ) + parent.children["employee_scale"] = replacement + parent.employee_scale = replacement + replacement.parent = parent + elif target == "canonical_deleted": + del ni.class_1.children["employee_scale"] + del ni.class_1.employee_scale + else: + bracket = ni.class_1.employee_scale.brackets[1] + del bracket.children["rate"] + del bracket.rate + with pytest.raises(ValueError, match="NI.*structurally replaced"): + changed.process_parameters() + assert ( + original.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + assert ( + original.parameters.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") + == 0.08 + ) + + +def test_supported_unrelated_structural_addition_survives_reset_and_processing(): + from policyengine_core.parameters import Parameter + + system = CountryTaxBenefitSystem() + leaf = Parameter("gov.test_222_extra", {"values": {"2026-01-01": 7}}) + system.parameters.gov.add_child("test_222_extra", leaf) + system.reset_parameters() + system.process_parameters() + assert system.parameters.gov.test_222_extra("2026") == 7 + assert ( + system.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_mixed_alias_and_canonical_order_keeps_one_dated_timeline(reverse): + from policyengine_uk.utils.scenario import Scenario + from policyengine_uk import Simulation + + old = PREFIX + "class_1.rates.employee.main" + new = PREFIX + LEAVES["class_1.rates.employee.main"] + entries = [ + (old, {"2026-04-06.2027-04-05": 0.11}), + (new, {"2026-04-06.2027-04-05": 0.17}), + ] + if reverse: + entries.reverse() + sim = Simulation( + situation={ + "people": {"p": {"age": {2026: 40}}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + }, + scenario=Scenario.from_reform(dict(entries)), + ) + params = sim.tax_benefit_system.parameters + leaf = params.get_child(new) + assert leaf("2026") == (0.11 if reverse else 0.17) + assert leaf("2027") == 0.08 + assert params.get_child(old) is leaf + assert params.baseline.get_child(old)("2026") == 0.08 + assert all(a.path == new for a in sim.tax_benefit_system.dated_assignments) + + +@pytest.mark.parametrize( + "old, value", + [ + ("class_1.thresholds.primary_threshold", 300), + ("class_1.thresholds.upper_earnings_limit", 1100), + ("class_1.thresholds.secondary_threshold", 200), + ("class_4.thresholds.lower_profits_limit", 15000), + ("class_4.thresholds.upper_profits_limit", 60000), + ], +) +def test_all_five_generated_thresholds_keep_legacy_and_canonical_reform_routes( + old, value +): + from policyengine_uk.utils.scenario import Scenario + from policyengine_uk import Simulation + from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + INDEXED_THRESHOLDS, + ) + + new = PREFIX + LEAVES[old] + assert new in INDEXED_THRESHOLDS + situations = { + "people": { + "p": { + "age": {2031: 40}, + "employment_income": {2031: 60000}, + "self_employment_income": {2031: 20000}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + a, b = [ + Simulation( + situation=situations, + scenario=Scenario.from_reform({path: {"2031-04-06.2032-04-05": value}}), + ) + for path in (PREFIX + old, new) + ] + for year in (2030, 2031, 2032, 2034): + left, right = a.tax_benefit_system.parameters, b.tax_benefit_system.parameters + assert left.get_child(new)(str(year)) == right.get_child(new)(str(year)) + assert left.get_child(PREFIX + old) is left.get_child(new) + assert a.tax_benefit_system.parameters.get_child(new)("2031") == value + import numpy as np + + np.testing.assert_array_equal( + a.calculate("national_insurance", 2031), b.calculate("national_insurance", 2031) + ) + np.testing.assert_array_equal( + a.calculate("household_net_income", 2031), + b.calculate("household_net_income", 2031), + ) + + +def test_raw_history_and_april_sampling_remain_distinct(): + system = CountryTaxBenefitSystem() + system.reset_parameters() + ni = system.parameters.gov.hmrc.national_insurance + assert ni.class_1.employee_scale.brackets[1].rate("2022-11-05") == 0.1325 + assert ni.class_1.employee_scale.brackets[1].rate("2022-11-06") == 0.12 + assert ni.class_1.rates.employee.main("2022-11-05") == 0.1325 + assert ni.class_1.rates.employee.main("2022-11-06") == 0.12 + system.process_parameters() + assert ( + system.parameters.gov.hmrc.national_insurance.class_1.employee_scale("2022") + .components[1] + .rate + == 0.1325 + ) + assert ( + system.parameters.gov.hmrc.national_insurance.class_1.rates.employee.main( + "2022" + ) + == 0.1325 + ) + + +def test_public_component_inputs_retain_override_and_aggregation_contract(): + from policyengine_uk import Simulation + from policyengine_uk.tests.test_ni_scale_equivalence import situation + + data = situation() + data["people"]["p0"]["ni_class_1_employee_primary"] = {2026: 123} + data["people"]["p0"]["ni_class_1_employee_additional"] = {2026: 45} + sim = Simulation(situation=data) + assert float(sim.calculate("ni_class_1_employee", 2026)[0]) == 168 + assert float(sim.calculate("national_insurance", 2026)[0]) == 168 + + +def test_legacy_parent_preserves_core_tracing_and_fresh_cache_reads(): + from types import SimpleNamespace + from policyengine_core.tracers import TracingParameterNodeAtInstant + + tree = CountryTaxBenefitSystem().parameters.clone() + parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + reads = [] + parent.trace = True + parent.tracer = SimpleNamespace( + record_parameter_access=lambda *args: reads.append(args) + ) + parent.branch_name = "test_222" + first = parent("2026") + assert isinstance(first, TracingParameterNodeAtInstant) + assert first.main == 0.08 + tree.get_child(PREFIX + "class_1.employee_scale[1].rate").update( + period="2026", value=0.21 + ) + assert parent("2026").main == 0.21 + assert [row[0] for row in reads] == [PREFIX + "class_1.rates.employee.main"] * 2 + assert [row[-1] for row in reads] == [0.08, 0.21] diff --git a/policyengine_uk/tests/test_ni_scale_equivalence.py b/policyengine_uk/tests/test_ni_scale_equivalence.py new file mode 100644 index 0000000000..05c86695bc --- /dev/null +++ b/policyengine_uk/tests/test_ni_scale_equivalence.py @@ -0,0 +1,156 @@ +"""Differential against frozen original NI formulas, plus independent cash oracles. + +The original formula sources are from UK e1601dbe4feb15e71d0d59b7a57f9d860741c068. +They restore only those formulas on the same parameter/input tree, not an entire +old model or historical legal entitlement. Parameter histories and metadata have +separate exact pre-migration fixtures; malformed reforms are compatibility tests. +""" + +import numpy as np +import pytest +from policyengine_core.reforms import Reform +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +ORIGINAL_SOURCES = { + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employee_primary(Variable):\n label = "NI Class 1 employee-side primary contributions"\n entity = Person\n definition_period = MONTH\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/8"\n\n def formula(person, period, parameters):\n income = person("ni_class_1_income", period)\n parameters = parameters(period).gov.hmrc.national_insurance.class_1\n\n # Thresholds are weekly, so multiply by weeks in year and divide by months in year\n primary_threshold = (\n parameters.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n upper_earnings_limit = (\n parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n\n upper_earnings_limit_income = max_(income - upper_earnings_limit, 0)\n primary_threshold_income = (\n max_(income - primary_threshold, 0) - upper_earnings_limit_income\n )\n\n return parameters.rates.employee.main * primary_threshold_income\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employee_additional(Variable):\n label = "NI Class 1 employee-side additional contributions"\n entity = Person\n definition_period = MONTH\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/8"\n\n def formula(person, period, parameters):\n income = person("ni_class_1_income", period)\n parameters = parameters(period).gov.hmrc.national_insurance.class_1\n upper_earnings_limit = (\n parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n upper_earnings_limit_income = max_(income - upper_earnings_limit, 0)\n return parameters.rates.employee.additional * upper_earnings_limit_income\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employer(Variable):\n value_type = float\n entity = Person\n label = "NI Class 1 employer-side contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_1_secondary_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/9"\n\n def formula(person, period, parameters):\n class_1 = parameters(period).gov.hmrc.national_insurance.class_1\n earnings = person("ni_class_1_income", period)\n if not parameters(\n period\n ).gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions:\n added_pension_contributions = person(\n "employer_pension_contributions", period\n )\n taxed_earnings = earnings + added_pension_contributions\n else:\n taxed_earnings = earnings\n secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR\n main_earnings = max_(\n taxed_earnings - secondary_threshold,\n 0,\n )\n return class_1.rates.employer * main_earnings\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4(Variable):\n value_type = float\n entity = Person\n label = "NI Class 4 contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_4_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/15"\n\n def formula(person, period, parameters):\n class_4 = parameters(period).gov.hmrc.national_insurance.class_4\n # Schedule 2: Class 4 is charged on the Chapter 2 trade profits less\n # the para 3 loss reliefs; Class 1 contributions are not a deduction.\n profits = person("ni_class_4_profits", period)\n add_rate_income = max_(\n profits - class_4.thresholds.upper_profits_limit,\n 0,\n )\n main_rate_income = (\n max_(\n profits - class_4.thresholds.lower_profits_limit,\n 0,\n )\n - add_rate_income\n )\n pre_maximum_amount = (\n main_rate_income * class_4.rates.main\n + add_rate_income * class_4.rates.additional\n )\n maximum_amount = person("ni_class_4_maximum", period)\n # Regulation 100(1): the annual maximum applies only where primary\n # Class 1 (or, before 6 April 2024, Class 2) contributions are also\n # payable for the year.\n includes_class_2 = class_4.annual_maximum.includes_class_2\n maximum_applies = (person("ni_class_1_employee", period) > 0) | np.logical_and(\n includes_class_2, person("ni_class_2", period) > 0\n )\n return max_(\n where(\n maximum_applies,\n min_(pre_maximum_amount, maximum_amount),\n pre_maximum_amount,\n ),\n 0,\n )\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4_main(Variable):\n value_type = float\n entity = Person\n label = "NI Class 4 main contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_4_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/15"\n\n def formula(person, period, parameters):\n class_4 = parameters(period).gov.hmrc.national_insurance.class_4\n profits = person("ni_class_4_profits", period)\n add_rate_income = max_(\n profits - class_4.thresholds.upper_profits_limit,\n 0,\n )\n main_rate_income = (\n max_(\n profits - class_4.thresholds.lower_profits_limit,\n 0,\n )\n - add_rate_income\n )\n return main_rate_income * class_4.rates.main\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4_maximum(Variable):\n label = "NI Class 4 maximum liability"\n entity = Person\n definition_period = YEAR\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/uksi/2001/1004/regulation/100"\n\n def formula(person, period, parameters):\n ni = parameters(period).gov.hmrc.national_insurance\n upl = ni.class_4.thresholds.upper_profits_limit\n lpl = ni.class_4.thresholds.lower_profits_limit\n step_1 = upl - lpl\n # Regulation 100 writes its percentages out, but in every year from\n # 2015-16 they match the Class 4 rates: 9% and 2% (Steps Two, Five,\n # Eight and Nine) to 2021-22 and in 2023-24; 9.73% and 2.73% for\n # 2022-23 (Health and Social Care Levy (Repeal) Act 2022 Sch para\n # 6(2)); from 2024-25, 6% and 100/6 in Steps Two and Five (National\n # Insurance Contributions (Reduction in Rates) Act 2024 s 2(3)) with\n # Steps Eight and Nine still at 2%. So the steps read the rate\n # parameters.\n main_rate = ni.class_4.rates.main\n add_rate = ni.class_4.rates.additional\n step_2 = step_1 * main_rate\n # Class 2 contributions drop out of regulation 100 from 6 April 2024.\n includes_class_2 = ni.class_4.annual_maximum.includes_class_2\n class_2_weeks_addition = 53 * ni.class_2.flat_rate * includes_class_2\n step_3 = step_2 + class_2_weeks_addition\n class_2_contributions = person("ni_class_2", period) * includes_class_2\n primary_class_1_contributions = person("ni_class_1_employee_primary", period)\n step_4_raw = step_3 - class_2_contributions - primary_class_1_contributions\n step_4 = max_(step_4_raw, 0)\n # Case 1: Step Four exceeds primary Class 1 + Class 2 + main-rate\n # Class 4. Main-rate Class 4 is Step Two less the main rate on the\n # unused part of the main band, so this is equivalent to\n # main rate x unused band + 53 weekly Class 2 rates\n # > 2 x (primary Class 1 + Class 2).\n # Comparing the totals directly left the case to float32 rounding\n # when they were equal (profits above the UPL, no Class 1 or 2), and\n # a wrong Case 1 dropped the additional-rate band (#1878). The unused\n # band is exactly zero at or above the UPL, so that equality is now\n # decided exactly.\n profits = person("ni_class_4_profits", period)\n unused_main_band = clip(upl - profits.astype(np.float64), 0, step_1)\n case_1 = (step_4_raw >= 0) & (\n main_rate * unused_main_band + class_2_weeks_addition\n > 2\n * (\n class_2_contributions.astype(np.float64)\n + primary_class_1_contributions.astype(np.float64)\n )\n )\n # Step Five converts Step Four back to profits at the main rate. With a\n # zero main rate (a reform abolishing it) there is no main-rate\n # liability to limit, so the headroom is unlimited instead of 0 / 0.\n step_5 = np.divide(\n step_4,\n main_rate,\n out=np.full_like(step_4, np.inf),\n where=main_rate > 0,\n )\n step_6 = min_(upl, profits) - lpl\n step_7 = max_(0, step_6 - step_5)\n step_8 = step_7 * add_rate\n step_9 = max_(0, profits - upl) * add_rate\n\n return where(case_1, step_4, step_4 + step_8 + step_9)\n', +} + +ORIGINAL_CLASSES = [] +for source_path, source in ORIGINAL_SOURCES.items(): + namespace = {} + exec(compile(source, source_path, "exec"), namespace) + ORIGINAL_CLASSES.append(namespace[source_path.rsplit("/", 1)[-1][:-3]]) + + +class OriginalNIFormulas(Reform): + def apply(self): + for variable in ORIGINAL_CLASSES: + self.update_variable(variable) + + +def situation(): + facts = [(40, 30000, 0), (40, 60000, 18000), (70, 12000, 70000), (40, 0, 20000)] + people = { + f"p{i}": { + "age": {2026: age}, + "employment_income": {2026: wage}, + "self_employment_income": {2026: profit}, + } + for i, (age, wage, profit) in enumerate(facts) + } + return { + "people": people, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(facts))}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": {2026: "LONDON"}, + "tenure_type": {2026: "OWNED_OUTRIGHT"}, + "council_tax": {2026: 0}, + } + for i in range(len(facts)) + }, + } + + +PREFIX = "gov.hmrc.national_insurance." +REFORMS = [ + {}, + {PREFIX + "class_1.rates.employee.main": {"2026": 0}}, + { + PREFIX + "class_1.thresholds.primary_threshold": {"2026": 200}, + PREFIX + "class_1.thresholds.upper_earnings_limit": {"2026": 200}, + }, + { + PREFIX + "class_1.thresholds.primary_threshold": {"2026": 300}, + PREFIX + "class_1.thresholds.upper_earnings_limit": {"2026": 200}, + }, + { + PREFIX + "class_4.thresholds.lower_profits_limit": {"2026": 20000}, + PREFIX + "class_4.thresholds.upper_profits_limit": {"2026": 10000}, + }, + { + "gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions": { + "2026": False + } + }, +] + + +@pytest.mark.parametrize("changes", REFORMS) +def test_component_and_final_cash_equal_frozen_original_formulas(changes): + current = Simulation(situation=situation(), scenario=Scenario.from_reform(changes)) + original = Simulation( + situation=situation(), + scenario=Scenario.from_reform((OriginalNIFormulas, changes)), + ) + outputs = [ + "ni_class_1_employee_primary", + "ni_class_1_employee_additional", + "ni_class_1_employee", + "ni_class_1_employer", + "ni_class_4_main", + "ni_class_4_maximum", + "ni_class_4", + "national_insurance", + "household_net_income", + ] + for name in outputs: + expected = original.calculate(name, 2026) + actual = current.calculate(name, 2026) + np.testing.assert_array_equal(actual, expected, err_msg=name) + assert actual.dtype == expected.dtype + # Query order cannot change a later output or the independent source system. + np.testing.assert_array_equal( + current.calculate("household_net_income", 2026), + original.calculate("household_net_income", 2026), + ) + + +def test_natural_2026_independent_ni_cash_examples(): + sim = Simulation(situation=situation()) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employee", 2026), + [1394.4032, 3210.6008, 0, 0], + atol=0.002, + rtol=0, + ) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employer", 2026), + [3750, 8250, 1050, 0], + atol=0.002, + rtol=0, + ) + assert float(sim.calculate("ni_class_4", 2026)[3]) == pytest.approx( + 445.8, abs=0.002 + ) + + +@pytest.mark.parametrize("route", ["dict", "core", "changes"]) +def test_scalar_and_canonical_leaf_reforms_have_same_natural_final_path(route): + old = {PREFIX + "class_1.rates.employee.main": {"2026": 0.1}} + new = {PREFIX + "class_1.employee_scale[1].rate": {"2026": 0.1}} + + def scenario(values): + if route == "changes": + return Scenario(parameter_changes=values) + if route == "core": + return Scenario.from_reform(Reform.from_dict(values)) + return Scenario.from_reform(values) + + a = Simulation(situation=situation(), scenario=scenario(old)) + b = Simulation(situation=situation(), scenario=scenario(new)) + np.testing.assert_array_equal( + a.calculate("ni_class_1_employee", 2026), + b.calculate("ni_class_1_employee", 2026), + ) + np.testing.assert_array_equal( + a.calculate("household_net_income", 2026), + b.calculate("household_net_income", 2026), + ) diff --git a/policyengine_uk/tests/test_ni_threshold_freeze.py b/policyengine_uk/tests/test_ni_threshold_freeze.py index 43cceb3712..f7fd758fa4 100644 --- a/policyengine_uk/tests/test_ni_threshold_freeze.py +++ b/policyengine_uk/tests/test_ni_threshold_freeze.py @@ -163,12 +163,26 @@ def test_no_threshold_falls_in_cash_terms(system, name): def test_frozen_year_values_cite_a_source(): """Every value dated in 2026-27 to 2030-31 carries a titled reference.""" for name, path in PATHS.items(): - file = ( - PARAMETER_DIR - / "gov/hmrc/national_insurance" - / (path.replace(".", "/") + ".yaml") - ) - values = yaml.safe_load(file.read_text())["values"] + component_paths = { + "primary_threshold": ("class_1/employee_scale.yaml", 1), + "upper_earnings_limit": ("class_1/employee_scale.yaml", 2), + "secondary_threshold": ("class_1/employer_scale.yaml", 1), + "lower_profits_limit": ("class_4/contribution_scale.yaml", 1), + "upper_profits_limit": ("class_4/contribution_scale.yaml", 2), + } + if name in component_paths: + relative, index = component_paths[name] + data = yaml.safe_load( + (PARAMETER_DIR / "gov/hmrc/national_insurance" / relative).read_text() + ) + values = data["brackets"][index]["threshold"]["values"] + else: + file = ( + PARAMETER_DIR + / "gov/hmrc/national_insurance" + / (path.replace(".", "/") + ".yaml") + ) + values = yaml.safe_load(file.read_text())["values"] dated = {str(instant): entry for instant, entry in values.items()} years = [2025, 2026] if name == "lower_earnings_limit" else FROZEN_YEARS for year in years: diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 23211c9960..52a14a8fc4 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -96,11 +96,11 @@ PATHS = { "personal_allowance": "income_tax/allowances/personal_allowance/amount.yaml", "basic_rate_limit": "income_tax/rates/uk.yaml", - "primary_threshold": "national_insurance/class_1/thresholds/primary_threshold.yaml", - "upper_earnings_limit": "national_insurance/class_1/thresholds/upper_earnings_limit.yaml", - "secondary_threshold": "national_insurance/class_1/thresholds/secondary_threshold.yaml", - "lower_profits_limit": "national_insurance/class_4/thresholds/lower_profits_limit.yaml", - "upper_profits_limit": "national_insurance/class_4/thresholds/upper_profits_limit.yaml", + "primary_threshold": "national_insurance/class_1/employee_scale.yaml", + "upper_earnings_limit": "national_insurance/class_1/employee_scale.yaml", + "secondary_threshold": "national_insurance/class_1/employer_scale.yaml", + "lower_profits_limit": "national_insurance/class_4/contribution_scale.yaml", + "upper_profits_limit": "national_insurance/class_4/contribution_scale.yaml", } @@ -112,19 +112,19 @@ def system(): def threshold_parameters(root): """The seven parameters under a ``gov`` node.""" hmrc = root.hmrc - class_1 = hmrc.national_insurance.class_1.thresholds - class_4 = hmrc.national_insurance.class_4.thresholds + class_1 = hmrc.national_insurance.class_1 + class_4 = hmrc.national_insurance.class_4 return { "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount, # Bracket 1 itself: a scale read at an instant sorts its thresholds, # so its thresholds[1] would be the £125,140 additional rate threshold # once the basic rate limit passed it. "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold, - "primary_threshold": class_1.primary_threshold, - "upper_earnings_limit": class_1.upper_earnings_limit, - "secondary_threshold": class_1.secondary_threshold, - "lower_profits_limit": class_4.lower_profits_limit, - "upper_profits_limit": class_4.upper_profits_limit, + "primary_threshold": class_1.employee_scale.brackets[1].threshold, + "upper_earnings_limit": class_1.employee_scale.brackets[2].threshold, + "secondary_threshold": class_1.employer_scale.brackets[1].threshold, + "lower_profits_limit": class_4.contribution_scale.brackets[1].threshold, + "upper_profits_limit": class_4.contribution_scale.brackets[2].threshold, } @@ -429,19 +429,71 @@ def series(base, first, last, month_day="04-06"): }, "national_insurance": { "class_1": { - "thresholds": { - name: series(bases[name], 2021, last_years[name]) - for name in ( - "primary_threshold", - "upper_earnings_limit", - "secondary_threshold", - ) - } + "employee_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["primary_threshold"], + 2021, + last_years["primary_threshold"], + ), + "rate": {"values": {"2021-01-01": 0.08}}, + }, + { + "threshold": series( + bases["upper_earnings_limit"], + 2021, + last_years["upper_earnings_limit"], + ), + "rate": {"values": {"2021-01-01": 0.02}}, + }, + ] + }, + "employer_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["secondary_threshold"], + 2021, + last_years["secondary_threshold"], + ), + "rate": {"values": {"2021-01-01": 0.15}}, + }, + ] + }, }, "class_4": { - "thresholds": { - name: series(bases[name], 2021, last_years[name]) - for name in ("lower_profits_limit", "upper_profits_limit") + "contribution_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["lower_profits_limit"], + 2021, + last_years["lower_profits_limit"], + ), + "rate": {"values": {"2021-01-01": 0.06}}, + }, + { + "threshold": series( + bases["upper_profits_limit"], + 2021, + last_years["upper_profits_limit"], + ), + "rate": {"values": {"2021-01-01": 0.02}}, + }, + ] } }, }, @@ -452,28 +504,9 @@ def series(base, first, last, month_day="04-06"): def synthetic_values(root, year): - hmrc = root.gov.hmrc instant = f"{year}-04-30" return { - "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount( - instant - ), - "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold(instant), - "primary_threshold": hmrc.national_insurance.class_1.thresholds.primary_threshold( - instant - ), - "upper_earnings_limit": hmrc.national_insurance.class_1.thresholds.upper_earnings_limit( - instant - ), - "secondary_threshold": hmrc.national_insurance.class_1.thresholds.secondary_threshold( - instant - ), - "lower_profits_limit": hmrc.national_insurance.class_4.thresholds.lower_profits_limit( - instant - ), - "upper_profits_limit": hmrc.national_insurance.class_4.thresholds.upper_profits_limit( - instant - ), + name: leaf(instant) for name, leaf in threshold_parameters(root.gov).items() } @@ -704,6 +737,21 @@ def test_indexed_parameters_carry_no_uprating(name): data = yaml.safe_load((PARAMETER_DIR / "gov/hmrc" / PATHS[name]).read_text()) if name == "basic_rate_limit": metadata = data["brackets"][1]["threshold"]["metadata"] + elif name in [ + "primary_threshold", + "upper_earnings_limit", + "secondary_threshold", + "lower_profits_limit", + "upper_profits_limit", + ]: + index = { + "primary_threshold": 1, + "upper_earnings_limit": 2, + "secondary_threshold": 1, + "lower_profits_limit": 1, + "upper_profits_limit": 2, + }[name] + metadata = data["brackets"][index]["threshold"]["metadata"] else: metadata = data["metadata"] assert "uprating" not in metadata diff --git a/policyengine_uk/utils/dated_reforms.py b/policyengine_uk/utils/dated_reforms.py index 59751d91bb..53ef5f7796 100644 --- a/policyengine_uk/utils/dated_reforms.py +++ b/policyengine_uk/utils/dated_reforms.py @@ -185,6 +185,12 @@ last_stated_tax_year, ) +from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_descendants, + canonicalize_ni_parameter_path, +) + from policyengine_uk.utils.parameters import ( BACKDATED_VALUE, check_parameter_not_removed, @@ -348,6 +354,7 @@ def read_reform_dict( legacy: List[LegacyOverride] = [] order = order_start for path, spec in reform.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) statutory = defines(path) if not isinstance(spec, Mapping): @@ -428,6 +435,7 @@ def read_parameter_changes( assignments: List[ParameterAssignment] = [] order = order_start for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) if not isinstance(spec, Mapping): start, stop = period_bounds(CORE_SCALAR_PERIOD) @@ -463,6 +471,7 @@ def read_reform_from_dict( legacy: List[LegacyOverride] = [] order = order_start for path, spec in parameter_values.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) if not defines(path): if isinstance(spec, Mapping): @@ -616,6 +625,7 @@ def read(spec): except (ValueError, TypeError, KeyError) as original: errors = [] for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) entries = spec.items() if isinstance(spec, Mapping) else [(None, spec)] for key, value in entries: try: @@ -805,6 +815,7 @@ def read(spec, predicate, warn=False): return pending, bool(pending) overrides = [] for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) change = {path: spec} assignments, legacy = read(change, defines) if _reform_writes_are_effective(assignments, legacy, system, written): @@ -986,7 +997,7 @@ def snapshot_timelines(node: ParameterNode) -> Dict[str, Timeline]: """ return { parameter.name: parameter.values_list - for parameter in node.get_descendants() + for parameter in canonical_descendants(node) if isinstance(parameter, Parameter) } @@ -1014,6 +1025,8 @@ def graft_missing_nodes( """ added = [] for key, child in source.children.items(): + if isinstance(child, LegacyNIParameter): + continue if root and key == "baseline": continue existing = target.children.get(key) diff --git a/policyengine_uk/utils/ni_parameter_scales.py b/policyengine_uk/utils/ni_parameter_scales.py new file mode 100644 index 0000000000..dfc07b0593 --- /dev/null +++ b/policyengine_uk/utils/ni_parameter_scales.py @@ -0,0 +1,334 @@ +"""Single-source NI scale leaves with legacy scalar read/update compatibility. + +Views are discoverable Parameters for existing metadata serializers. Numerical +processing explicitly excludes them; no view owns a second policy timeline. +""" + +import copy +from contextlib import contextmanager +from dataclasses import dataclass +from types import MappingProxyType + +from policyengine_core.commons import empty_clone +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale + +NI_PREFIX = "gov.hmrc.national_insurance." +NI_LEGACY_PATHS = MappingProxyType( + { + NI_PREFIX + old: NI_PREFIX + new + for old, new in { + "class_1.rates.employee.main": "class_1.employee_scale[1].rate", + "class_1.rates.employee.additional": "class_1.employee_scale[2].rate", + "class_1.thresholds.primary_threshold": "class_1.employee_scale[1].threshold", + "class_1.thresholds.upper_earnings_limit": "class_1.employee_scale[2].threshold", + "class_1.rates.employer": "class_1.employer_scale[1].rate", + "class_1.thresholds.secondary_threshold": "class_1.employer_scale[1].threshold", + "class_4.rates.main": "class_4.contribution_scale[1].rate", + "class_4.rates.additional": "class_4.contribution_scale[2].rate", + "class_4.thresholds.lower_profits_limit": "class_4.contribution_scale[1].threshold", + "class_4.thresholds.upper_profits_limit": "class_4.contribution_scale[2].threshold", + }.items() + } +) + + +def canonicalize_ni_parameter_path(path): + prefix = "" + while path.startswith("baseline."): + prefix += "baseline." + path = path[len("baseline.") :] + return prefix + NI_LEGACY_PATHS.get(path, path) + + +@dataclass(frozen=True) +class NIComponent: + """Original bracket identity, even when custom thresholds coincide/cross.""" + + threshold: object + rate: object + + +class NIParameterScale(ParameterScale): + def _get_at_instant(self, instant): + scale = super()._get_at_instant(instant) + scale.components = tuple( + NIComponent(bracket.threshold(instant), bracket.rate(instant)) + for bracket in self.brackets + ) + return scale + + +class LegacyNIParameter(Parameter): + """A named scalar descriptor whose values belong to a canonical NI leaf.""" + + def __init__(self, name, target_path): + self.name = name + self.target_path = target_path + self.parent = None + self._at_instant_cache = {} + + @property + def target(self): + node = self.parent + while node is not None and not getattr(node, "_ni_scale_anchor", False): + node = getattr(node, "parent", None) + if node is None: + raise ValueError( + f"Detached NI compatibility view {self.name} has no policy tree" + ) + return node.get_child(self.target_path) + + @property + def values_list(self): + return self.target.values_list + + @values_list.setter + def values_list(self, value): + target = self.target + target.values_list = value + target.clear_parent_cache() + target.mark_as_modified() + + @property + def metadata(self): + return self.target.metadata + + @metadata.setter + def metadata(self, value): + self.target.metadata = value + + @property + def description(self): + return self.target.description + + @description.setter + def description(self, value): + self.target.description = value + + @property + def documentation(self): + return getattr(self.target, "documentation", None) + + @property + def file_path(self): + return self.target.file_path + + def update(self, *args, **kwargs): + return self.target.update(*args, **kwargs) + + def _get_at_instant(self, instant): + return self.target.get_at_instant(instant) + + def clear_parent_cache(self): + self.target.clear_parent_cache() + + def mark_as_modified(self): + self.target.mark_as_modified() + + def clone(self): + # A standalone leaf clone is an independent ordinary Parameter, as it + # was before migration. Parent cloning recreates descriptors instead. + leaf = self.target.clone() + leaf.name = self.name + owner = ParameterNode("", data={}) + owner.add_child("leaf", leaf) + return leaf + + +class NICompatibilityNode(ParameterNode): + def clone(self): + clone = empty_clone(self) + clone.__dict__ = self.__dict__.copy() + clone.metadata = copy.deepcopy(self.metadata) + clone.children = { + key: LegacyNIParameter(child.name, child.target_path) + if isinstance(child, LegacyNIParameter) + else child.clone() + for key, child in self.children.items() + } + for key, child in clone.children.items(): + setattr(clone, key, child) + child.parent = clone + clone._at_instant_cache = {} + return clone + + def _get_at_instant(self, instant): + # A sibling canonical update clears the root's cache, not a legacy + # sibling's cache. Rebuild this cheap numeric view on every lookup. + self._at_instant_cache.pop(instant, None) + return super()._get_at_instant(instant) + + +class _CanonicalChildren(dict): + """Explicit processing iteration, retaining legacy key lookups.""" + + def items(self): + return ( + (key, value) + for key, value in super().items() + if not isinstance(value, LegacyNIParameter) + ) + + def values(self): + return (value for _, value in self.items()) + + def __iter__(self): + return (key for key, _ in self.items()) + + +def canonical_descendants(node): + return ( + child + for child in node.get_descendants() + if not isinstance(child, LegacyNIParameter) + ) + + +@contextmanager +def canonical_ni_processing(parameters): + """Only on an owned processing tree; restore discovery even on errors. + + The real root/leaves and their parents retain identity. Core's nested + traversal and its independent child/path walker see canonical leaves; + explicit legacy lookup still resolves the view. No global/caller mode. + """ + saved = [] + try: + for node in list(parameters.get_descendants()): + if isinstance(node, ParameterNode) and any( + isinstance(child, LegacyNIParameter) for child in node.children.values() + ): + saved.append((node, node.children)) + node.children = _CanonicalChildren(node.children) + yield parameters + finally: + for node, children in reversed(saved): + node.children = children + + +@contextmanager +def canonical_ni_uprating_references(parameters): + """Map NI dependency names for Core's canonical-leaf ordering only.""" + saved = [] + try: + for leaf in canonical_descendants(parameters): + if not isinstance(leaf, Parameter): + continue + value = leaf.metadata.get("uprating") + path = ( + value + if isinstance(value, str) + else (value.get("parameter") if isinstance(value, dict) else None) + ) + if path is None: + continue + canonical = canonicalize_ni_parameter_path(path) + if canonical != path: + saved.append((leaf.metadata, value)) + leaf.metadata["uprating"] = ( + canonical + if isinstance(value, str) + else { + **value, + "parameter": canonical, + } + ) + yield parameters + finally: + for metadata, value in reversed(saved): + metadata["uprating"] = value + + +def add_ni_scale_compatibility(parameters): + """Install relative, clone-safe views without copied scalar histories.""" + # Baseline copies use their own NI anchor and canonical components. + roots = [parameters] + if "baseline" in parameters.children: + roots.append(parameters.children["baseline"]) + for root in roots: + ni = root.gov.hmrc.national_insurance + for name in ( + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", + ): + scale = ni.get_child(name) + scale.__class__ = NIParameterScale + ni._ni_scale_anchor = True + for old, new in NI_LEGACY_PATHS.items(): + parts = old[len(NI_PREFIX) :].split(".") + node = ni + for part in parts[:-1]: + node.__class__ = NICompatibilityNode + if part not in node.children: + node.add_child( + part, NICompatibilityNode(node.name + "." + part, data={}) + ) + node = node.children[part] + if not isinstance(node, ParameterNode): + raise ValueError( + f"NI legacy subtree {old} was structurally replaced; use {new}" + ) + node.__class__ = NICompatibilityNode + previous = node.children.get(parts[-1]) + if previous is not None and not isinstance(previous, LegacyNIParameter): + raise ValueError( + f"NI legacy leaf {old} was structurally replaced; update {new}" + ) + if previous is None: + node.add_child(parts[-1], LegacyNIParameter(old, new[len(NI_PREFIX) :])) + return parameters + + +def validate_ni_scale_compatibility(parameters): + """Reject conflicting subtree replacement before numerical processing. + + Leaf updates and unrelated added nodes are supported. Replacing either + spelling's structure is not silently interpreted as a second schedule. + """ + roots = [parameters] + if "baseline" in parameters.children: + roots.append(parameters.children["baseline"]) + for root in roots: + try: + ni = root.gov.hmrc.national_insurance + except (AttributeError, ValueError) as error: + raise ValueError( + "NI canonical subtree was structurally replaced; retain the schedules and update component leaves" + ) from error + for path, count in ( + ("class_1.employee_scale", 3), + ("class_1.employer_scale", 2), + ("class_4.contribution_scale", 3), + ): + try: + scale = ni.get_child(path) + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI canonical schedule {path} was structurally replaced; update its component leaves" + ) from error + if not isinstance(scale, NIParameterScale) or len(scale.brackets) != count: + raise ValueError( + f"NI canonical schedule {path} was structurally replaced; update its component leaves" + ) + for old, new in NI_LEGACY_PATHS.items(): + node = ni + try: + for part in old[len(NI_PREFIX) :].split("."): + node = node.children[part] + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI legacy path {old} was structurally replaced; update {new}" + ) from error + try: + valid = isinstance( + node, LegacyNIParameter + ) and node.target is root.get_child(new) + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI canonical leaf {new} was structurally replaced; update its value" + ) from error + if not valid: + raise ValueError( + f"NI legacy path {old} was structurally replaced; update {new}" + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index d21202ccb3..5445ea2e0c 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -4,6 +4,12 @@ from policyengine_core.model_api import * from policyengine_core import periods +from policyengine_uk.utils.ni_parameter_scales import ( + add_ni_scale_compatibility, + canonicalize_ni_parameter_path, + canonical_descendants, +) + from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( canonicalize_lsr_parameter_path, ) @@ -25,7 +31,7 @@ def str_to_instant(s): def backdate_parameters(root: str = None, first_instant: str = "2021-01-01") -> Reform: first_instant = str_to_instant(first_instant) node = root - for param in node.get_descendants(): + for param in canonical_descendants(node): if isinstance(param, Parameter): earliest = param.values_list[-1] earliest_value = earliest.value @@ -117,7 +123,7 @@ def convert_to_fiscal_year_parameters(parameters): """ # Cover years from 2015 through 2040 for long-term projections YEARS = list(range(2015, 2041)) - for param in parameters.get_descendants(): + for param in canonical_descendants(parameters): if isinstance(param, Parameter): if (param.metadata or {}).get("preserve_calendar_dates", False): continue @@ -213,7 +219,7 @@ class RemovedParameterRoot(ParameterNode): def get_child(self, path: str): check_parameter_not_removed(path) - return super().get_child(path) + return super().get_child(canonicalize_ni_parameter_path(path)) def add_removed_parameter_aliases(parameters: ParameterNode) -> ParameterNode: @@ -253,4 +259,4 @@ def add_removed_parameter_aliases(parameters: ParameterNode) -> ParameterNode: parent.children[name] = alias setattr(parent, name, alias) alias.parent = parent - return parameters + return add_ni_scale_compatibility(parameters) diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py index 863a35adfa..d4e2d50547 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py @@ -29,8 +29,8 @@ def formula(person, period, parameters): # Calculate employer cost parameters = parameters(period) class_1 = parameters.gov.hmrc.national_insurance.class_1 - r_r = class_1.rates.employer - t_r = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_r = class_1.employer_scale.components[1].rate + t_r = class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_r = parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py index 3f32b7291e..0a929b7530 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py @@ -39,8 +39,8 @@ def formula(person, period, parameters): period ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 - r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_b = baseline_class_1.employer_scale.components[1].rate + t_b = baseline_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py index be4aa17063..37af8cc502 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py @@ -47,8 +47,8 @@ def formula(person, period, parameters): ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 - r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_b = baseline_class_1.employer_scale.components[1].rate + t_b = baseline_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b else 0 @@ -60,8 +60,8 @@ def formula(person, period, parameters): # Calculate reform employer cost reform_parameters = parameters(period) reform_class_1 = reform_parameters.gov.hmrc.national_insurance.class_1 - r_r = reform_class_1.rates.employer - t_r = reform_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_r = reform_class_1.employer_scale.components[1].rate + t_r = reform_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_r = reform_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r else 0 diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py index d9d7e6f167..eb62e5f8b3 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -87,10 +87,10 @@ def formula(person, period, parameters): income_tax.rates.uk.rates[0], ) trading_tax = basic_rate * max_(0, profit - personal_allowance) - trading_class_4 = class_4.rates.main * max_( + trading_class_4 = class_4.contribution_scale.components[1].rate * max_( 0, - min_(profit, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, + min_(profit, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, ) # Reg. 14(2)(a), omitted from 6 April 2024: the Class 2 contributions # payable on the chargeable profit (SSCBA 1992 s. 11), on the diff --git a/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py b/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py index 4f82cef51c..eeb7ab10a2 100644 --- a/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py +++ b/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py @@ -100,13 +100,17 @@ def formula(person, period, parameters): tax_on_pay = max_(0, tax_on_pay - mca_reduction) nics = p.gov.hmrc.national_insurance class_1 = nics.class_1 - primary_threshold = class_1.thresholds.primary_threshold * WEEKS_IN_YEAR - upper_earnings_limit = class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR + primary_threshold = ( + class_1.employee_scale.components[1].threshold * WEEKS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) pay_above_upper_limit = max_(0, pay - upper_earnings_limit) class_1_on_pay = person("ni_liable", period) * ( - class_1.rates.employee.main + class_1.employee_scale.components[1].rate * (max_(0, pay - primary_threshold) - pay_above_upper_limit) - + class_1.rates.employee.additional * pay_above_upper_limit + + class_1.employee_scale.components[2].rate * pay_above_upper_limit ) pension_from_pay = ( employee_pension + personal_pension - personal_pension_from_profit @@ -135,11 +139,13 @@ def formula(person, period, parameters): class_4 = nics.class_4 class_4_band = max_( 0, - min_(profit, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, + min_(profit, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, ) notional_class_4 = ( - person("ni_class_4_liable", period) * class_4.rates.main * class_4_band + person("ni_class_4_liable", period) + * class_4.contribution_scale.components[1].rate + * class_4_band ) # Reg 99(3)(a), until 5 April 2024: Class 2 at the weekly rate unless # the profit is below the small profits threshold or, from 6 April diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py index 19025089f2..4d601ff00c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py @@ -35,11 +35,15 @@ def formula(person, period, parameters): # Primary Class 1 on pay equal to the threshold, with the annual # thresholds (weekly amounts times 52) the model uses for employees. class_1 = ni.class_1 - primary_threshold = class_1.thresholds.primary_threshold * WEEKS_IN_YEAR - upper_earnings_limit = class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR - employee = class_1.rates.employee.main * max_( + primary_threshold = ( + class_1.employee_scale.components[1].threshold * WEEKS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) + employee = class_1.employee_scale.components[1].rate * max_( 0, min_(threshold, upper_earnings_limit) - primary_threshold - ) + class_1.rates.employee.additional * max_( + ) + class_1.employee_scale.components[2].rate * max_( 0, threshold - upper_earnings_limit ) # Class 2 and Class 4 on profits equal to the threshold, by the same @@ -51,12 +55,12 @@ def formula(person, period, parameters): * class_2_contribution_weeks(period.start.year) ) class_4 = ni.class_4 - class_4_amount = class_4.rates.main * max_( + class_4_amount = class_4.contribution_scale.components[1].rate * max_( 0, - min_(threshold, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, - ) + class_4.rates.additional * max_( - 0, threshold - class_4.thresholds.upper_profits_limit + min_(threshold, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, + ) + class_4.contribution_scale.components[2].rate * max_( + 0, threshold - class_4.contribution_scale.components[2].threshold ) # Each class is due only from those the model treats as liable for # it, as for the person's actual contributions. Primary Class 1 and diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py index 6f8dffd2c2..e8fa159750 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py @@ -13,7 +13,11 @@ def formula(person, period, parameters): income = person("ni_class_1_income", period) parameters = parameters(period).gov.hmrc.national_insurance.class_1 upper_earnings_limit = ( - parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[2].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) - return parameters.rates.employee.additional * upper_earnings_limit_income + return ( + parameters.employee_scale.components[2].rate * upper_earnings_limit_income + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py index 69b5db4986..0e52ff0af2 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py @@ -15,10 +15,14 @@ def formula(person, period, parameters): # Thresholds are weekly, so multiply by weeks in year and divide by months in year primary_threshold = ( - parameters.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[1].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit = ( - parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[2].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) @@ -26,4 +30,4 @@ def formula(person, period, parameters): max_(income - primary_threshold, 0) - upper_earnings_limit_income ) - return parameters.rates.employee.main * primary_threshold_income + return parameters.employee_scale.components[1].rate * primary_threshold_income diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py index a179befa83..c4ad894ea7 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py @@ -22,9 +22,11 @@ def formula(person, period, parameters): taxed_earnings = earnings + added_pension_contributions else: taxed_earnings = earnings - secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + secondary_threshold = ( + class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR + ) main_earnings = max_( taxed_earnings - secondary_threshold, 0, ) - return class_1.rates.employer * main_earnings + return class_1.employer_scale.components[1].rate * main_earnings diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py index 0657f27c97..eba6649452 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py @@ -16,19 +16,19 @@ def formula(person, period, parameters): # the para 3 loss reliefs; Class 1 contributions are not a deduction. profits = person("ni_class_4_profits", period) add_rate_income = max_( - profits - class_4.thresholds.upper_profits_limit, + profits - class_4.contribution_scale.components[2].threshold, 0, ) main_rate_income = ( max_( - profits - class_4.thresholds.lower_profits_limit, + profits - class_4.contribution_scale.components[1].threshold, 0, ) - add_rate_income ) pre_maximum_amount = ( - main_rate_income * class_4.rates.main - + add_rate_income * class_4.rates.additional + main_rate_income * class_4.contribution_scale.components[1].rate + + add_rate_income * class_4.contribution_scale.components[2].rate ) maximum_amount = person("ni_class_4_maximum", period) # Regulation 100(1): the annual maximum applies only where primary diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py index 8ba64c53b3..98c4a91e0d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py @@ -14,14 +14,14 @@ def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 profits = person("ni_class_4_profits", period) add_rate_income = max_( - profits - class_4.thresholds.upper_profits_limit, + profits - class_4.contribution_scale.components[2].threshold, 0, ) main_rate_income = ( max_( - profits - class_4.thresholds.lower_profits_limit, + profits - class_4.contribution_scale.components[1].threshold, 0, ) - add_rate_income ) - return main_rate_income * class_4.rates.main + return main_rate_income * class_4.contribution_scale.components[1].rate diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py index f599002047..69128f473b 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py @@ -11,8 +11,8 @@ class ni_class_4_maximum(Variable): def formula(person, period, parameters): ni = parameters(period).gov.hmrc.national_insurance - upl = ni.class_4.thresholds.upper_profits_limit - lpl = ni.class_4.thresholds.lower_profits_limit + upl = ni.class_4.contribution_scale.components[2].threshold + lpl = ni.class_4.contribution_scale.components[1].threshold step_1 = upl - lpl # Regulation 100 writes its percentages out, but in every year from # 2015-16 they match the Class 4 rates: 9% and 2% (Steps Two, Five, @@ -22,8 +22,8 @@ def formula(person, period, parameters): # Insurance Contributions (Reduction in Rates) Act 2024 s 2(3)) with # Steps Eight and Nine still at 2%. So the steps read the rate # parameters. - main_rate = ni.class_4.rates.main - add_rate = ni.class_4.rates.additional + main_rate = ni.class_4.contribution_scale.components[1].rate + add_rate = ni.class_4.contribution_scale.components[2].rate step_2 = step_1 * main_rate # Class 2 contributions drop out of regulation 100 from 6 April 2024. includes_class_2 = ni.class_4.annual_maximum.includes_class_2 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index ba71d85791..611db8e75d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -23,14 +23,16 @@ def formula(person, period, parameters): # Use existing NI Class 1 parameters ni_params = parameters(period).gov.hmrc.national_insurance.class_1 employment_income = person("employment_income", period) - upper_earnings_limit = ni_params.thresholds.upper_earnings_limit * WEEKS_IN_YEAR + upper_earnings_limit = ( + ni_params.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) # Apply appropriate NI rate based on income level # Main rate (8%) for income <= UEL, additional rate (2%) for income > UEL ni_rate = where( employment_income <= upper_earnings_limit, - ni_params.rates.employee.main, - ni_params.rates.employee.additional, + ni_params.employee_scale.components[1].rate, + ni_params.employee_scale.components[2].rate, ) return excess * ni_rate diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..5b99dcae2d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -22,6 +22,6 @@ def formula(person, period, parameters): # Use existing NI Class 1 employer rate parameter ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.rates.employer + employer_rate = ni_params.employer_scale.components[1].rate return excess * employer_rate From e889563e8ad30466a1b040bbb54e2501f835f3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:48:11 +0100 Subject: [PATCH 046/141] Reject unsupported NI zero-band reforms --- .../222-ni-scale-parameters.changed.md | 2 + .../gov/hmrc/national-insurance-scales.md | 13 +- .../tests/test_ni_parameter_scales.py | 146 ++++++++++++++++++ policyengine_uk/utils/ni_parameter_scales.py | 22 +++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/changelog.d/222-ni-scale-parameters.changed.md b/changelog.d/222-ni-scale-parameters.changed.md index 6bd8e6ab56..b2ed649f24 100644 --- a/changelog.d/222-ni-scale-parameters.changed.md +++ b/changelog.d/222-ni-scale-parameters.changed.md @@ -1 +1,3 @@ - Store marginal National Insurance schedules as parameter scales while preserving contribution calculations and existing scalar reform paths. +- Reject unsupported changes to the fixed zero-rate, zero-threshold NI scale sentinel with a targeted error. +- Direct YAML readers must migrate the ten removed scalar files to the canonical scale files; parameter-tree readers retain the legacy compatibility paths. diff --git a/docs/book/programs/gov/hmrc/national-insurance-scales.md b/docs/book/programs/gov/hmrc/national-insurance-scales.md index f1c9599305..71ed6b0ffe 100644 --- a/docs/book/programs/gov/hmrc/national-insurance-scales.md +++ b/docs/book/programs/gov/hmrc/national-insurance-scales.md @@ -5,7 +5,11 @@ parameter scales. Each schedule holds its contribution rates and the thresholds at which they apply. The schedules retain their dated values and units. All paths below start with `gov.hmrc.national_insurance.`. Bracket indices start -at zero; bracket zero supplies the initial zero-rate band. +at zero. Bracket zero supplies the fixed zero-threshold, zero-rate sentinel. +Contribution reforms use brackets 1 and 2 below. Any nonzero, missing or null +threshold or rate in bracket zero is rejected with a targeted error naming the +canonical leaf. Validation covers its full dated history, including future +entries; an explicit update to zero leaves calculations unchanged. | Scale | Contribution bands | Threshold period in the parameter files | | --- | --- | --- | @@ -67,5 +71,12 @@ references, units and uprating metadata supply the legacy scalar views. The NI threshold generator writes to these canonical leaves. Parameter processing visits each numerical history once; metadata discovery also exposes the legacy names. +Metadata consumers should map a legacy name to its canonical leaf and present +one control for that history. Both spellings remain discoverable, so listing +every metadata record can duplicate a control. The pinned, source-extracted API +contract tests cover parameter records and updates; they do not verify a +deployed API or the app's tree, search and control presentation. Consumer owners +must check those surfaces before relying on the migration in a UI. + For NI rate reforms in behavioural scenarios, see [Labour supply dynamics](../../../usage/dynamics.md). diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py index 2eba48fffc..41438ccc55 100644 --- a/policyengine_uk/tests/test_ni_parameter_scales.py +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -407,3 +407,149 @@ def test_legacy_parent_preserves_core_tracing_and_fresh_cache_reads(): assert parent("2026").main == 0.21 assert [row[0] for row in reads] == [PREFIX + "class_1.rates.employee.main"] * 2 assert [row[-1] for row in reads] == [0.08, 0.21] + + +_NI_ZERO_SCALES = ( + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", +) +_NI_ZERO_LEAVES = tuple( + (scale, leaf) for scale in _NI_ZERO_SCALES for leaf in ("threshold", "rate") +) + + +def _ni_zero_error(scale, leaf): + import re + + path = PREFIX + scale + "[0]." + leaf + return r"NI.*" + re.escape(path) + r".*fixed at zero" + + +def _ni_zero_situation(): + return { + "people": { + "p": { + "age": {2026: 40}, + "employment_income": {2026: 60_000}, + "self_employment_income": {2026: 30_000}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": { + "h": { + "members": ["p"], + "region": {2026: "LONDON"}, + "tenure_type": {2026: "OWNED_OUTRIGHT"}, + "council_tax": {2026: 0}, + } + }, + } + + +def _ni_zero_scenario(route, changes): + from policyengine_core.reforms import Reform + from policyengine_uk.utils.scenario import Scenario + + if route == "changes": + return Scenario(parameter_changes=changes) + if route == "core": + return Scenario.from_reform(Reform.from_dict(changes)) + if route == "callable": + + class UpdateNILeaves(Reform): + def apply(self): + def update(parameters): + for path, values in changes.items(): + parameters.get_child(path).update( + period="2026", value=values["2026"] + ) + return parameters + + self.modify_parameters(update) + + return Scenario.from_reform(UpdateNILeaves) + return Scenario.from_reform(changes) + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("route", ["dict", "changes", "core", "callable"]) +def test_ni_zero_band_reforms_are_rejected(scale, leaf, route): + from policyengine_uk import Simulation + + path = PREFIX + scale + "[0]." + leaf + changes = {path: {"2026": 0.5 if leaf == "rate" else 1}} + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + sim = Simulation( + situation=_ni_zero_situation(), + scenario=_ni_zero_scenario(route, changes), + ) + sim.calculate("national_insurance", 2026) + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("baseline", [False, True]) +def test_ni_zero_band_future_edits_cannot_hide_behind_a_cached_read( + scale, leaf, baseline +): + source = CountryTaxBenefitSystem().parameters + tree = source.clone() + target = tree.baseline if baseline else tree + target("2026") + path = PREFIX + scale + "[0]." + leaf + target.get_child(path).update(start="2041-01-01", value=1) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + target("2026") + assert source.get_child(path)("2041") == 0 + assert source.baseline.get_child(path)("2041") == 0 + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("operation", ["process_parameters", "reset_parameter_caches"]) +def test_ni_zero_band_validation_rejects_updates_without_mutating_source( + scale, leaf, operation +): + source = CountryTaxBenefitSystem() + changed = source.clone() + if operation == "process_parameters": + changed.reset_parameters() + path = PREFIX + scale + "[0]." + leaf + changed.parameters.get_child(path).update(period="2026", value=1) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + getattr(changed, operation)() + assert source.parameters.get_child(path)("2026") == 0 + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("damage", ["missing", "null"]) +def test_ni_zero_band_malformed_leaves_raise_the_targeted_error(scale, leaf, damage): + tree = CountryTaxBenefitSystem().parameters.clone() + raw_scale = tree.get_child(PREFIX + scale) + bracket = raw_scale.brackets[0] + if damage == "missing": + del bracket.children[leaf] + delattr(bracket, leaf) + else: + bracket.children[leaf].update(period="2026", value=None) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + raw_scale("2026") + + +@pytest.mark.parametrize("scale", _NI_ZERO_SCALES) +@pytest.mark.parametrize("route", ["dict", "changes", "core", "callable"]) +def test_ni_zero_band_explicit_zero_updates_are_noops(scale, route): + import numpy as np + from policyengine_uk import Simulation + + changes = { + PREFIX + scale + "[0]." + leaf: {"2026": 0} for leaf in ("threshold", "rate") + } + original = Simulation(situation=_ni_zero_situation()) + changed = Simulation( + situation=_ni_zero_situation(), + scenario=_ni_zero_scenario(route, changes), + ) + for output in ("national_insurance", "ni_class_1_employer", "household_net_income"): + np.testing.assert_array_equal( + changed.calculate(output, 2026), original.calculate(output, 2026) + ) diff --git a/policyengine_uk/utils/ni_parameter_scales.py b/policyengine_uk/utils/ni_parameter_scales.py index dfc07b0593..4152b88d3f 100644 --- a/policyengine_uk/utils/ni_parameter_scales.py +++ b/policyengine_uk/utils/ni_parameter_scales.py @@ -2,6 +2,10 @@ Views are discoverable Parameters for existing metadata serializers. Numerical processing explicitly excludes them; no view owns a second policy timeline. +Retire the Core-internal adaptations when stable alias/processing contracts +replace them. Remove legacy views through an explicit compatibility migration +once callers, saved reforms and metadata consumers use canonical scale paths; +retain their contract tests until that transition is complete. """ import copy @@ -48,8 +52,25 @@ class NIComponent: rate: object +def _validate_ni_zero_bracket(scale): + """The initial zero band is a fixed representation sentinel.""" + bracket = scale.brackets[0] + for name in ("threshold", "rate"): + leaf = bracket.children.get(name) + if ( + not isinstance(leaf, Parameter) + or not leaf.values_list + or any(point.value != 0 for point in leaf.values_list) + ): + raise ValueError( + f"NI canonical leaf {scale.name}[0].{name} is fixed at zero; " + "update the contribution bands after bracket 0 instead" + ) + + class NIParameterScale(ParameterScale): def _get_at_instant(self, instant): + _validate_ni_zero_bracket(self) scale = super()._get_at_instant(instant) scale.components = tuple( NIComponent(bracket.threshold(instant), bracket.rate(instant)) @@ -311,6 +332,7 @@ def validate_ni_scale_compatibility(parameters): raise ValueError( f"NI canonical schedule {path} was structurally replaced; update its component leaves" ) + _validate_ni_zero_bracket(scale) for old, new in NI_LEGACY_PATHS.items(): node = ni try: From d7ed2889a7bc94d69c6ca10aa8f1b1296643293c Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 13:13:31 +0200 Subject: [PATCH 047/141] Correct three planning formulas and a broken link from review - Take-up: count only eligible units in the numerator and treat a zero denominator as undefined. - Future-dated announcements: a reversal removes or replaces the cancelled row at its own effective date; a new row at the reversal date leaves the future row in force. - BSP: pay the single initial payment from a claim-year input, so an 18-month bereavement window can't pay it twice; keep the 18 months for the instalments. - PIP points plan: close the DWP statistics link. Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/takeup-analysis-plan.md | 9 +++++-- docs/book/policy/future-date-announcements.md | 16 ++++++++---- .../gov/dwp/bereavement-support-plan.md | 26 ++++++++++++------- docs/book/programs/gov/dwp/pip-points-plan.md | 2 +- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/book/assumptions/takeup-analysis-plan.md b/docs/book/assumptions/takeup-analysis-plan.md index d4836b0de7..64cd8e5679 100644 --- a/docs/book/assumptions/takeup-analysis-plan.md +++ b/docs/book/assumptions/takeup-analysis-plan.md @@ -93,8 +93,13 @@ For each programme above, the analysis should produce: analysis year, plus the source (HMRC, DWP or an academic study) and vintage. 2. **Effective take-up rate post-pipeline:** counted from the built - dataset as `sum(would_claim_X * weight) / sum(eligible_X * weight)`, - where `eligible_X` is computed from the runtime formulas. + dataset as + `sum(would_claim_X * eligible_X * weight) / sum(eligible_X * weight)`, + where `eligible_X` is computed from the runtime formulas. The + numerator counts only eligible units, because `would_claim_X` can be + true for ineligible units too (otherwise one eligible unit and two + willing units would give 200%). Where no unit is eligible the + denominator is zero; report the rate as undefined (n/a), not 0%. 3. **Published outturn share** for the same year: DWP and HMRC statistical publications giving claimant counts and either eligible population counts (where DWP estimates them) or a published take-up diff --git a/docs/book/policy/future-date-announcements.md b/docs/book/policy/future-date-announcements.md index b0649e838b..8e91ec8ce7 100644 --- a/docs/book/policy/future-date-announcements.md +++ b/docs/book/policy/future-date-announcements.md @@ -64,11 +64,17 @@ Truss "growth plan" basic-rate cut to 19% (announced September 2022, reversed October 2022) is the canonical example. To represent this honestly: -1. Add the announced value at the announced effective date with a - reference to the announcement. -2. Add a *second* row at the reversal date with the original value (or - the actual landing value) and a reference to the reversal - announcement. +1. When the announcement is made, add the announced value at the + announced effective date with a reference to the announcement (for + the Truss case, 19% from 6 April 2023). +2. When it is reversed, remove that row, or replace its value with the + value that actually applies from that date (20% from 6 April 2023), + and cite the reversal announcement. A new row at the reversal date + does not cancel the future row: parameter rows apply from their own + dates, so a 20% row dated October 2022 is followed by the 19% row in + April 2023, and fiscal-year conversion still gives 19% for 2023. +3. Keep the announcement history in the row's references and in version + control rather than in a live value. The baseline then follows what was finally enacted. PolicyEngine UK has no "policy as announced on date X" switch, so to score an announcement as diff --git a/docs/book/programs/gov/dwp/bereavement-support-plan.md b/docs/book/programs/gov/dwp/bereavement-support-plan.md index 86da510bd1..a8160f9926 100644 --- a/docs/book/programs/gov/dwp/bereavement-support-plan.md +++ b/docs/book/programs/gov/dwp/bereavement-support-plan.md @@ -69,22 +69,30 @@ children, with effect back to 30 August 2018, after the Supreme Court's To model this we need: -- `is_bereaved_recently` (Person, YEAR): a new input flagging - whether the person has been bereaved in the last 18 months. +- `bsp_initial_payment_due` (Person, YEAR): a new input that is true + only in the year the BSP claim is made after the partner's death, so + the [single initial payment](https://www.gov.uk/bereavement-support-payment/what-youll-get) + is paid once. A flag for "bereaved in the last 18 months" would stay + true across two consecutive annual periods and pay the lump sum twice. - `bsp_higher_rate_eligible` (Person, YEAR): whether they have a dependent child. - `bsp_months_remaining` (Person, YEAR): how many of the 18 monthly - instalments fall within the simulation year. + instalments fall within the simulation year (0 to 12). This is where + the 18-month window belongs. The BSP formula then becomes: ``` bsp = ( - lump_sum_amount * is_bereaved_recently + lump_sum_amount * bsp_initial_payment_due + monthly_amount * bsp_months_remaining ) ``` +An alternative is a single date-of-death (or claim-date) input from +which both the initial-payment year and the months in each year are +derived. Either way the lump sum is tied to one year only. + Both `lump_sum_amount` and `monthly_amount` parameter trees split on `bsp_higher_rate_eligible` (£3,500 / £350 vs £2,500 / £100). @@ -113,11 +121,11 @@ the current data they sit inside `bsp_reported` (see above), so they are already counted in income; the gap is that they can't be told apart from BSP or reformed separately. -### Phase 3: data-side bereavement flag +### Phase 3: data-side bereavement inputs -`is_bereaved_recently` doesn't exist in the FRS. The FRS is a -cross-section, not a panel, and doesn't record when a partner died, so -the flag would need imputing in the data (Microcosm UK, as the +Neither `bsp_initial_payment_due` nor `bsp_months_remaining` exists in +the FRS. The FRS is a cross-section, not a panel, and doesn't record +when a partner died, so these inputs would need imputing in the data (Microcosm UK, as the replacement dataset): - start from FRS respondents who report BSP (once it is separated from @@ -126,7 +134,7 @@ replacement dataset): and region. This is the heaviest of the three phases; without it the new -variables work for the household calculator (which can input the flag +variables work for the household calculator (which can input them directly) but not for microsim aggregates. ## Open questions diff --git a/docs/book/programs/gov/dwp/pip-points-plan.md b/docs/book/programs/gov/dwp/pip-points-plan.md index 33afd2ef86..67a20e8091 100644 --- a/docs/book/programs/gov/dwp/pip-points-plan.md +++ b/docs/book/programs/gov/dwp/pip-points-plan.md @@ -160,7 +160,7 @@ machinery (for reform analysis) get the activity-level surface. - [The Social Security (Personal Independence Payment) Regulations 2013 (SI 2013/377)][si-377]: Schedule 1, Parts 2 and 3 define the point system. - DWP, [Personal Independence Payment: Assessment guide][assess-guide]. -- DWP, [PIP statistics](https://www.gov.uk/government/collections/personal-independence-payment-statistics: caseload by component and band. +- DWP, [PIP statistics](https://www.gov.uk/government/collections/personal-independence-payment-statistics): caseload by component and band. - DWP, [Modernising support for independent living: the health and disability green paper (April 2024)][green-paper]: proposals on the descriptors. - DWP, [Pathways to Work: Reforming Benefits and Support to Get Britain Working (March 2025)](https://www.gov.uk/government/consultations/pathways-to-work-reforming-benefits-and-support-to-get-britain-working-green-paper): the four-point proposal. - Issue: [#1069](https://github.com/PolicyEngine/policyengine-uk/issues/1069). From 16d4c7b665a1cb85fbcbc1fc63cc5544e52d50ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:42:29 +0100 Subject: [PATCH 048/141] Fix program registry model references --- changelog.d/2116.fixed.md | 1 + policyengine_uk/programs.yaml | 7 ++-- .../tests/test_program_registry.py | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2116.fixed.md create mode 100644 policyengine_uk/tests/test_program_registry.py diff --git a/changelog.d/2116.fixed.md b/changelog.d/2116.fixed.md new file mode 100644 index 0000000000..e4b2cd5717 --- /dev/null +++ b/changelog.d/2116.fixed.md @@ -0,0 +1 @@ +- Corrected the JSA, ESA and DLA program registry variable mappings, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 2fa867458d..d8fa3f87b7 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -249,7 +249,7 @@ programs: agency: DWP status: complete coverage: UK - variable: JSA + variable: jsa parameter_prefix: gov.dwp.JSA verified_start_year: 2022 notes: Income-based and contribution-based @@ -261,7 +261,7 @@ programs: agency: DWP status: complete coverage: UK - variable: ESA + variable: esa parameter_prefix: gov.dwp.ESA verified_start_year: 2022 notes: Income-related and contribution-based @@ -296,7 +296,7 @@ programs: agency: DWP status: complete coverage: UK - variable: DLA + variable: dla parameter_prefix: gov.dwp.dla verified_start_year: 2022 @@ -377,7 +377,6 @@ programs: status: complete coverage: UK variable: maternity_allowance - parameter_prefix: gov.dwp verified_start_year: 2022 # --- DfE programs --- diff --git a/policyengine_uk/tests/test_program_registry.py b/policyengine_uk/tests/test_program_registry.py new file mode 100644 index 0000000000..df0eafb84a --- /dev/null +++ b/policyengine_uk/tests/test_program_registry.py @@ -0,0 +1,41 @@ +"""Keep the public program registry linked to real model objects.""" + +import pytest +import yaml +from policyengine_core.parameters import ParameterNode + +from policyengine_uk import CountryTaxBenefitSystem + +PROGRAMS = yaml.safe_load(CountryTaxBenefitSystem.modelled_policies.read_text())[ + "programs" +] + + +@pytest.fixture(scope="module") +def tax_benefit_system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "program", [p for p in PROGRAMS if "variable" in p], ids=lambda p: p["id"] +) +def test_program_variables_exist(program, tax_benefit_system): + assert program["variable"] in tax_benefit_system.variables, ( + f"{program['id']} references unknown variable {program['variable']!r}" + ) + + +@pytest.mark.parametrize( + "program", [p for p in PROGRAMS if "parameter_prefix" in p], ids=lambda p: p["id"] +) +def test_program_parameter_prefixes_exist(program, tax_benefit_system): + prefix = program["parameter_prefix"] + assert isinstance(tax_benefit_system.parameters.get_child(prefix), ParameterNode), ( + f"{program['id']} parameter prefix {prefix!r} does not name a parameter node" + ) + + +def test_maternity_allowance_does_not_advertise_unrelated_parameters(): + """Reported Maternity Allowance has no policy parameter subtree (#2116).""" + program = next(p for p in PROGRAMS if p["id"] == "maternity_allowance") + assert "parameter_prefix" not in program From d6fe42c1976ec8cdb2cac81521b699eae1032fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:33:14 +0100 Subject: [PATCH 049/141] Fix issues from review: narrow Benefit Cap registry prefix --- changelog.d/2116.fixed.md | 2 +- policyengine_uk/programs.yaml | 2 +- policyengine_uk/tests/test_program_registry.py | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/changelog.d/2116.fixed.md b/changelog.d/2116.fixed.md index e4b2cd5717..87f51f8729 100644 --- a/changelog.d/2116.fixed.md +++ b/changelog.d/2116.fixed.md @@ -1 +1 @@ -- Corrected the JSA, ESA and DLA program registry variable mappings, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. +- Corrected the JSA, ESA and DLA program registry variable mappings, narrowed Benefit Cap's parameter prefix to its own subtree, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index d8fa3f87b7..11657e674c 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -319,7 +319,7 @@ programs: status: complete coverage: UK variable: benefit_cap_reduction - parameter_prefix: gov.dwp + parameter_prefix: gov.dwp.benefit_cap verified_start_year: 2022 notes: Universal Credit (uc_benefit_cap) and Housing Benefit (housing_benefit_benefit_cap) each apply their own cap; benefit_cap_reduction is the sum of their reductions diff --git a/policyengine_uk/tests/test_program_registry.py b/policyengine_uk/tests/test_program_registry.py index df0eafb84a..f4214066d8 100644 --- a/policyengine_uk/tests/test_program_registry.py +++ b/policyengine_uk/tests/test_program_registry.py @@ -39,3 +39,18 @@ def test_maternity_allowance_does_not_advertise_unrelated_parameters(): """Reported Maternity Allowance has no policy parameter subtree (#2116).""" program = next(p for p in PROGRAMS if p["id"] == "maternity_allowance") assert "parameter_prefix" not in program + + +def test_benefit_cap_does_not_advertise_unrelated_parameters(tax_benefit_system): + """The advertised Benefit Cap subtree excludes other DWP programs (#2116).""" + program = next(p for p in PROGRAMS if p["id"] == "benefit_cap") + advertised = tax_benefit_system.parameters.get_child(program["parameter_prefix"]) + benefit_cap = tax_benefit_system.parameters.gov.dwp.benefit_cap + + assert "universal_credit" not in advertised.children + assert "JSA" not in advertised.children + assert advertised.get_child("single.in_london") is benefit_cap.single.in_london + assert ( + advertised.get_child("non_single.outside_london") + is benefit_cap.non_single.outside_london + ) From 56d21a837fc7292bf4a344b10bf984c433ed5532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:23:36 +0100 Subject: [PATCH 050/141] Document domestic and overseas carbon flows --- .../fix-478-carbon-flow-docs.changed.md | 1 + .../contrib/ubi-center/carbon-tax.ipynb | 70 ++++++++----------- myst.yml | 1 + 3 files changed, 33 insertions(+), 39 deletions(-) create mode 100644 changelog.d/fix-478-carbon-flow-docs.changed.md diff --git a/changelog.d/fix-478-carbon-flow-docs.changed.md b/changelog.d/fix-478-carbon-flow-docs.changed.md new file mode 100644 index 0000000000..e6b5d5cc94 --- /dev/null +++ b/changelog.d/fix-478-carbon-flow-docs.changed.md @@ -0,0 +1 @@ +Clarify domestic and overseas carbon flows and household incidence in the carbon-tax documentation. diff --git a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb index 4695c82a59..1399ad3172 100644 --- a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb +++ b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb @@ -7,53 +7,45 @@ "source": [ "# Carbon taxes\n", "\n", - "A carbon tax is a tax levied on the carbon emissions required to produce goods and services. In this way, they are designed to reduce carbon dioxide emissions by increasing prices. This both decreases demand for such goods and services and incentivizes efforts to make them less carbon-intensive. In its simplest form, a carbon tax covers only CO2 emissions; however, it can also cover other greenhouse gasses, such as methane or nitrous oxide, by calculating their global warming potential relative to CO2 emissions. PolicyEngine’s carbon tax model is consumption-based - that is, the tax is levied on the UK’s consumption of CO2-intensive goods, rather than the UK’s production of CO2. Table 1 shows the incidence of this carbon tax model:\n", + "This page describes PolicyEngine UK's contributed carbon-tax model. It estimates a hypothetical household burden from consumption-related emissions, then allocates that burden between consumers and exposure to the corporate sector. It does not simulate an enacted carbon tax or a border adjustment.\n", "\n", - "Table 1\n", + "## Domestic and overseas flows\n", "\n", + "Production location and consumption location are different questions. The model estimates the footprint of UK households' consumption without a country-of-production dimension; it does not measure each of the following flows separately.\n", "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
\n", - " UK\n", - " Non-UK\n", - "
Owners of production\n", - " Reality: directly taxed by a carbon emissions tax, not indirectly taxed.\n", - "

\n", - "Model: Each owner pays s% of x total UK ownership share, in aggregate passes on (1-s)% to consumers.\n", - "

Reality: indirectly taxed by a border adjustment, not directly taxed.\n", - "

\n", - "Model: assumed non-existent.\n", - "

Consumers\n", - " Reality: indirectly taxed by the carbon emissions tax and directly taxed by the border adjustment, depending on sectoral demand elasticities.\n", - "

\n", - "Model: Each consumer pays (1 - s)% * total UK consumption share of each consumption sector.\n", - "

Reality: not directly or indirectly taxed.\n", - "

\n", - "Model: not directly or indirectly taxed.\n", - "

\n", + "| Where goods and services are produced | Where they are consumed | What the household model represents |\n", + "| --- | --- | --- |\n", + "| UK | UK households | Part of the household consumption footprint, without a separate domestic-production component. |\n", + "| Outside the UK | UK households | No separate imported-emissions variable. Any overseas production represented by the consumption-intensity inputs remains combined with domestic production; the formulas cannot identify its share or calculate a border adjustment. |\n", + "| UK | Outside the UK | No foreign-consumer population or separate export flow in the carbon-tax calculation. Stored UK production series are not its tax base. |\n", + "| Outside the UK | Outside the UK | No separate rest-of-world consumption or production flow in the UK household calculation. |\n", "\n", + "This distinction also applies to ownership. A household's exposure to corporate taxes does not identify where a company produces goods or where its customers live. The model does not calculate a separate burden for foreign owners or consumers, or separate employer and wage effects.\n", "\n", - "## Methodology summary\n", + "## From consumption to emissions\n", "\n", - "We model carbon taxes by simulating household net incomes through the microsimulation model. The first step: collecting the Family Resources Survey inputs: data on earnings, benefit receipt and demographics for 20,000 households. Then, we load the Living Costs and Food Survey, which has less granular data on earnings, and benefit receipt, but detailed capture of goods and services produced: we impute these consumption variables to the FRS. We then use the National Carbon Footprint dataset to calculate the intensities of certain categories (e.g. the carbon emissions associated with a pound spent on clothing), and use this to estimate the carbon footprint of each FRS household. After we integrate these carbon footprints to the FRS, we run a full microsimulation to simulate disposable incomes with and without a carbon tax.\n", + "[`carbon_consumption`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/consumption/carbon.py) is an annual household estimate in tonnes of CO2. It adds spending in twelve consumption categories, each multiplied by that category's carbon intensity. The intensity is aggregate category emissions divided by aggregate category spending; when that spending is nonpositive, the formula uses the stored fallback intensity.\n", "\n", + "The [carbon parameters](https://github.com/PolicyEngine/policyengine-uk/tree/main/policyengine_uk/parameters/household/consumption/carbon) also contain production and total-consumption series. The household footprint and carbon-tax formulas do not read those series to construct a territorial inventory or a production-to-consumption trade balance. Their presence does not establish separate coverage of all four flows above.\n", "\n", - "### Appendix: Carbon intensities" + "Consumption inputs, intensities and survey weights determine the resulting footprint. A sum over the simulated households is not by itself a validated UK national emissions total; that interpretation also requires suitable data and weights. This page describes the calculation, without validating a particular dataset's imputation or source-country coverage.\n", + "\n", + "## How the burden is allocated\n", + "\n", + "For a household with footprint `E_h`, the [`carbon_tax` formula](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/ubi_center/carbon_tax.py) is:\n", + "\n", + "```text\n", + "E_total = sum(household_weight_h * E_h)\n", + "carbon_tax_h = c * r * E_h + (1 - c) * r * E_total * shareholding_h\n", + "```\n", + "\n", + "Here `r` is the hypothetical price per tonne and `c` is the consumer-incidence fraction, both set by the [carbon-tax parameters](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/contrib/ubi_center/carbon_tax.yaml). The first term allocates the consumer share by the household's footprint. The second allocates the remaining burden using [`shareholding`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/wealth/shareholding.py), the household's modelled corporate-sector wealth exposure. With a dataset and nonzero corporate wealth, that exposure is normalised by weighted household corporate wealth; otherwise the formula uses the national-balance-sheet denominator.\n", + "\n", + "This is an allocation assumption, not a calculation of sector-specific demand elasticities, international ownership or the legal incidence of an import tax. Changing the incidence fraction changes who bears the modelled burden, without adding a country-of-production breakdown.\n", + "\n", + "### Appendix: illustrative carbon intensities\n", + "\n", + "The chart below uses fixed illustrative intensities in its code cell. It does not query the current carbon parameters and is not a new estimate or validation of domestic and overseas emissions.\n" ] }, { diff --git a/myst.yml b/myst.yml index 54e94000bb..5d58330cbd 100644 --- a/myst.yml +++ b/myst.yml @@ -37,6 +37,7 @@ project: - pattern: docs/book/policy/*.md - title: Programmes children: + - file: docs/book/programs/contrib/ubi-center/carbon-tax.ipynb - pattern: docs/book/programs/gov/*/*.md site: options: From 00b8e9bad43e23b26401f6b58b7c0f04deeb9223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:33:24 +0100 Subject: [PATCH 051/141] Fix issues from review: clarify carbon footprint sources --- changelog.d/fix-478-carbon-flow-docs.changed.md | 2 +- docs/book/programs/contrib/ubi-center/carbon-tax.ipynb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/fix-478-carbon-flow-docs.changed.md b/changelog.d/fix-478-carbon-flow-docs.changed.md index e6b5d5cc94..9d41be4104 100644 --- a/changelog.d/fix-478-carbon-flow-docs.changed.md +++ b/changelog.d/fix-478-carbon-flow-docs.changed.md @@ -1 +1 @@ -Clarify domestic and overseas carbon flows and household incidence in the carbon-tax documentation. +Clarify domestic and overseas carbon flows and household incidence, including imported emissions in the consumption footprint and the chart's 2019 DEFRA fallback intensities. diff --git a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb index 1399ad3172..af05368b97 100644 --- a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb +++ b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb @@ -16,7 +16,7 @@ "| Where goods and services are produced | Where they are consumed | What the household model represents |\n", "| --- | --- | --- |\n", "| UK | UK households | Part of the household consumption footprint, without a separate domestic-production component. |\n", - "| Outside the UK | UK households | No separate imported-emissions variable. Any overseas production represented by the consumption-intensity inputs remains combined with domestic production; the formulas cannot identify its share or calculate a border adjustment. |\n", + "| Outside the UK | UK households | [DEFRA's consumption-based footprint](https://www.gov.uk/government/statistics/uks-carbon-footprint) includes emissions embedded in imports. The model combines these with domestic emissions without a UK/overseas production split or a border-adjustment calculation. |\n", "| UK | Outside the UK | No foreign-consumer population or separate export flow in the carbon-tax calculation. Stored UK production series are not its tax base. |\n", "| Outside the UK | Outside the UK | No separate rest-of-world consumption or production flow in the UK household calculation. |\n", "\n", @@ -43,9 +43,9 @@ "\n", "This is an allocation assumption, not a calculation of sector-specific demand elasticities, international ownership or the legal incidence of an import tax. Changing the incidence fraction changes who bears the modelled burden, without adding a country-of-production breakdown.\n", "\n", - "### Appendix: illustrative carbon intensities\n", + "### Appendix: 2019 DEFRA fallback intensities\n", "\n", - "The chart below uses fixed illustrative intensities in its code cell. It does not query the current carbon parameters and is not a new estimate or validation of domestic and overseas emissions.\n" + "The chart below uses the 2019 DEFRA fallback intensities stored in the [carbon-intensity parameters](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/household/consumption/carbon/intensity.yaml). Its code cell fixes these twelve values and does not query the current carbon parameters. The chart is not a new estimate or validation of domestic and overseas emissions.\n" ] }, { From 2f75e2d16c964f8f5640544e097fafdfe20467a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:00:01 +0100 Subject: [PATCH 052/141] Apply annual employee NI for full-year category A directors in 2022 --- changelog.d/721-director-2022.fixed.md | 1 + .../gov/hmrc/national-insurance-directors.md | 189 +++++++-------- .../employee_scale.yaml | 21 ++ .../in_effect.yaml | 11 + .../ni_class_1_full_year_director_2022.yaml | 143 ++++++++++++ .../tests/test_ni_full_year_director_2022.py | 217 ++++++++++++++++++ .../class_1/ni_class_1_employee_additional.py | 17 +- .../class_1/ni_class_1_employee_primary.py | 18 +- .../input/is_full_year_category_a_director.py | 18 ++ 9 files changed, 529 insertions(+), 106 deletions(-) create mode 100644 changelog.d/721-director-2022.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml create mode 100644 policyengine_uk/tests/test_ni_full_year_director_2022.py create mode 100644 policyengine_uk/variables/input/is_full_year_category_a_director.py diff --git a/changelog.d/721-director-2022.fixed.md b/changelog.d/721-director-2022.fixed.md new file mode 100644 index 0000000000..485e548b1d --- /dev/null +++ b/changelog.d/721-director-2022.fixed.md @@ -0,0 +1 @@ +- Added the 2022–23 annual employee National Insurance schedule for explicitly declared full-year category A directors in one employment, preserving ordinary employees and other years. diff --git a/docs/book/programs/gov/hmrc/national-insurance-directors.md b/docs/book/programs/gov/hmrc/national-insurance-directors.md index 1f0f8b1281..2590dde242 100644 --- a/docs/book/programs/gov/hmrc/national-insurance-directors.md +++ b/docs/book/programs/gov/hmrc/national-insurance-directors.md @@ -1,106 +1,87 @@ -# Company-director NI (annual cumulative basis) - -```{note} -PolicyEngine UK applies a **single annual NI calculation** at the -person level. For most users this matches what HMRC charges company -directors at year end. This page explains the relationship between the -director-specific "annual cumulative" basis HMRC uses and PolicyEngine's -current modelling, and describes what would need to change to expose a -dedicated director-specific path. Tracked under -[#721](https://github.com/PolicyEngine/policyengine-uk/issues/721). +# Company-director National Insurance + +## Supported 2022–23 employee schedule + +For model year `2022`, the explicit Person/year input +`is_full_year_category_a_director` selects the employee primary Class 1 +schedule for a person who was a director throughout the tax year, had one +employment and was in category A throughout. The input defaults to `False`; +the model does not infer it from salary, dividends or company ownership. +The input label names its supported 2022–23 period. In other model years, +even an explicit `True` leaves the ordinary calculation unchanged. + +HMRC's [2022–23 employer guidance][rates] gives this annual director schedule: + +| Annual NI earnings | Employee rate | +| --- | ---: | +| Up to £11,908 | 0% | +| Above £11,908 up to £50,270 | 12.73% | +| Above £50,270 | 2.73% | + +For example, £30,000 of annual NI earnings produces £2,303.1116 before +monetary rounding: (£30,000 − £11,908) × 12.73%. The model retains its existing +floating-point representation; this is not a new payroll rounding rule. +The director route uses the existing `ni_class_1_income` earnings definition, +including its statutory-pay components, and the existing final `ni_liable` +age gate. It does not change employer NI, income tax or other NI classes. + +A policy-test input for this restricted case is: + +```yaml +period: 2022 +input: + age: 40 + employment_income: 30000 + is_full_year_category_a_director: true +output: + ni_class_1_employee: 2303.1116 ``` -## What HMRC's annual cumulative basis is - -Class 1 NI is normally charged on each *earnings period* (typically a -week or a month) — the employer applies the weekly/monthly thresholds -to that pay period's earnings in isolation. Directors are a special -case because their pay is often irregular: a director might take £0 in -salary for eleven months and then a £200,000 bonus in December. Under -period-by-period NI rules, that pattern would generate massive NI in -the bonus month and almost none in the other eleven, even though the -director's annual income is no different from someone earning a steady -salary. - -To prevent this avoidance route, [SSCBA 1992 s.122 and the *Social -Security (Contributions) Regulations 2001*][nim12000] require directors -to use an **annual earnings period** by default: - -1. The director's NI is computed on a **cumulative** year-to-date - basis. -2. The annual thresholds (Primary Threshold, Upper Earnings Limit, - Secondary Threshold) are applied to YTD earnings, not to each pay - period's earnings in isolation. -3. NI is charged once YTD earnings cross each threshold, and never - refunded if earnings drop later in the year. - -There is also an "**alternative**" method, which approximates the -annual basis with monthly cumulative figures, used for in-year -operational simplicity; the final year-end reconciliation still uses -the annual basis. - -## How PolicyEngine currently handles this - -PolicyEngine UK already computes NI **annually** for every person: - -- The Class 1 employee primary and additional formulas in - [`variables/gov/hmrc/national_insurance/class_1/`](https://github.com/PolicyEngine/policyengine-uk/tree/main/policyengine_uk/variables/gov/hmrc/national_insurance/class_1) - read weekly thresholds from `gov.hmrc.national_insurance.class_1.thresholds` - and convert them to monthly (`× WEEKS_IN_YEAR / MONTHS_IN_YEAR`) before - applying the rate. -- Because the input `employment_income` is annual and is implicitly - spread evenly across months, the **annual NI** the model produces is - identical to the annual cumulative figure a director would owe — for - someone earning the same total over the year, the two methods give - the same answer. - -That's why microsimulation results don't depend on director status. - -## Where the current model could diverge from HMRC's director rules - -Three situations could in principle produce a different answer: - -1. **Irregular within-year pay**. If a household-calculator user inputs - an annual `employment_income` of, say, £80,000 but means it as a - single lump-sum payment, PolicyEngine still spreads it across twelve - months — matching the annual cumulative outcome. There is no - surface here for the user to express the pay timing. -2. **Mid-year start or end** of directorship within a tax year. The - pro-rating rules for directors who start or cease being a director - part-way through a year are slightly different from regular - employees — PolicyEngine assumes a full-year period everywhere. -3. **Multiple employments**. A director with two separate company - roles can sometimes have NI calculated separately per employment. - PolicyEngine has a single `employment_income` per person and so - collapses this case. - -In practice (1) and (2) only matter for the household-calculator -front-end, not microsimulation. (3) is a wider modelling gap that -applies beyond directors. - -## What a dedicated director path would look like - -A future PR addressing this fully would add: - -- A new boolean input `is_company_director` (Person, year). -- A documentation flag on `ni_class_1_employee_primary` / `_additional` - noting that for directors with `is_company_director == true` the - formula returns the same annual figure as the regular path (rather - than period-by-period NI), confirming the equivalence. -- Optional follow-up: a `company_director_pay_timing` enum so that - household-calculator users who model a director's actual pay pattern - can document their intent, but the resulting NI stays on the annual - basis. - -For now PolicyEngine is **functionally correct** for any user whose -input is an annual income — which is the expected interface — and -no code changes are needed in the short term. - -## References - -- HMRC, [NIM12000 — Class 1 NICs: company directors](https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000) — primary internal guidance covering the annual cumulative basis. -- HMRC, [CA44 — National Insurance for company directors](https://www.gov.uk/government/publications/ca44-national-insurance-for-company-directors) — the operational employer guide. -- [The Social Security (Contributions) Regulations 2001 (SI 2001/1004) reg. 8](https://www.legislation.gov.uk/uksi/2001/1004/regulation/8) — the regulation that imposes the annual earnings period. -- [Social Security Contributions and Benefits Act 1992 s. 8](https://www.legislation.gov.uk/ukpga/1992/4/section/8) — primary statute for Class 1 employee NI. - -[nim12000]: https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000 +The primary and additional component variables allocate their respective +annual liabilities equally over twelve model months. Adding the months +recovers the annual liability. These allocations are **not** statutory +interim payroll deductions, observed monthly cash, cumulative year-to-date +charges or a model of bonus timing. The annual earnings interface does not +represent when pay was received. + +## Why this differs from the ordinary route + +The ordinary employee route reads weekly thresholds and converts them to +monthly equivalents. Existing fiscal parameter processing samples 30 April: +model year `2022` therefore uses the ordinary employee rate and threshold +in force on 30 April 2022. That convention does not reproduce the distinct +annual director schedule above. Annual salary inputs alone do not make the +two routes equivalent for 2022–23. + +The new director parameters store annual thresholds, with raw dates starting +6 April 2022. A separate implementation-coverage parameter is true only for +2022–23, mapping to model year `2022` under that same fiscal convention. +Its false value from 6 April 2023 limits this implementation; it does not +mean that director law ended then. No ordinary rate or threshold history is +changed. Broader fiscal-year representation remains separate work under +[#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807). + +## Remaining limits + +HMRC describes a [standard annual method and an alternative method][directors]. +The alternative method uses pay-period calculations during the year and +reconciles to the annual amount at year end. This page and the model's new +route concern the supported full-year annual liability, not either method's +in-year payment sequence. + +Appointment years, category changes, multiple employments, other years and +employer-side director NI remain outside this implementation. The separate +employer rate and threshold in HMRC's table are not employee inputs. An +explicit contribution output supplied by a user retains the existing input +override contract; the new fact does not validate that supplied amount. + +This is a partial response to +[#721](https://github.com/PolicyEngine/policyengine-uk/issues/721), which +remains open. It introduces no director imputation, population estimate or +encoded policy-parity claim. Integration with the separate NI scale migration +in [PR #2490](https://github.com/PolicyEngine/policyengine-uk/pull/2490) requires +coordination of the two employee component files; tests on this branch alone +do not establish that combined-tree compatibility. + +[rates]: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 +[directors]: https://www.gov.uk/employee-directors diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml new file mode 100644 index 0000000000..d1e3e0e5e2 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml @@ -0,0 +1,21 @@ +description: The annual primary Class 1 National Insurance schedule for a full-year category A director in one employment in 2022–23. +brackets: + - threshold: + 2022-04-06: 0 + rate: + 2022-04-06: 0 + - threshold: + 2022-04-06: 11908 + rate: + 2022-04-06: 0.1273 + - threshold: + 2022-04-06: 50270 + rate: + 2022-04-06: 0.0273 +metadata: + label: Full-year category A director annual employee NI 2022–23 + threshold_unit: currency-GBP + rate_unit: /1 + reference: + - title: HMRC rates and thresholds for employers 2022 to 2023, annual director primary thresholds and category A rates + href: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml new file mode 100644 index 0000000000..d080a26b80 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml @@ -0,0 +1,11 @@ +description: The model applies the full-year category A director employee schedule for the supported 2022–23 tax year. +values: + 0000-01-01: false + 2022-04-06: true + 2023-04-06: false +metadata: + label: Full-year category A director 2022–23 implementation coverage + unit: bool + reference: + - title: HMRC rates and thresholds for employers 2022 to 2023, company directors + href: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml new file mode 100644 index 0000000000..a000b8c814 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml @@ -0,0 +1,143 @@ +# Literal annual employee-primary expectations from HMRC's 2022–23 director +# table: PT £11,908, UEL £50,270, category A rates 12.73% and 2.73%. +# Annual model amounts, not interim payroll deductions. All are adults below SPA. +- name: Full-year category A director below the annual primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0 + +- name: Full-year category A director at the annual primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11908 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0 + +- name: Full-year category A director one pound above the primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11909 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0.1273 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0.1273 + +- name: Full-year category A director in the main band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 30000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 2303.1116 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 2303.1116 + +- name: Full-year category A director one pound below the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50269 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.3553 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 4883.3553 + +- name: Full-year category A director at the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50270 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 4883.4826 + +- name: Full-year category A director one pound above the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50271 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 0.0273 + ni_class_1_employee: 4883.5099 + +- name: Full-year category A director above the upper band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 100000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 1357.629 + ni_class_1_employee: 6241.1116 + +- name: Natural salary household pays director NI and unchanged income tax + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + household_owns_tv: false + employment_income: 30000 + is_full_year_category_a_director: true + would_claim_uc: false + output: + ni_class_1_income: 30000 + ni_liable: true + income_tax: 3486 + ni_class_1_employee: 2303.1116 + national_insurance: 2303.1116 + # Existing calendar-2022 support: council-tax rebate 150 + EBSS 199. + ebr_council_tax_rebate: 150 + ebr_energy_bills_credit: 199 + household_benefits: 349 + tv_licence: 0 + +- name: Natural higher salary household reconciles both director bands and cash + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + household_owns_tv: false + employment_income: 60000 + is_full_year_category_a_director: true + would_claim_uc: false + output: + ni_class_1_income: 60000 + income_tax: 11432 + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 265.629 + ni_class_1_employee: 5149.1116 + national_insurance: 5149.1116 + # Existing calendar-2022 support: council-tax rebate 150 + EBSS 199. + ebr_council_tax_rebate: 150 + ebr_energy_bills_credit: 199 + household_benefits: 349 + tv_licence: 0 diff --git a/policyengine_uk/tests/test_ni_full_year_director_2022.py b/policyengine_uk/tests/test_ni_full_year_director_2022.py new file mode 100644 index 0000000000..da2c667767 --- /dev/null +++ b/policyengine_uk/tests/test_ni_full_year_director_2022.py @@ -0,0 +1,217 @@ +"""The declared 2022–23 annual employee schedule, not interim director payroll.""" + +from pathlib import Path + +import numpy as np +import pytest +from policyengine_core.parameters import load_parameter_file + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.utils.scenario import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +DIRECTORS = "gov.hmrc.national_insurance.class_1.directors.full_year_category_a_2022_23" +DIRECTORY = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23" +) +COMPONENTS = ( + "ni_class_1_employee_primary", + "ni_class_1_employee_additional", + "ni_class_1_employee", +) + + +def situation(year=2022, flags=(True,), earnings=(30000,), age=40): + people = {} + for i, earnings_i in enumerate(earnings): + people[f"p{i}"] = { + "age": {year: age}, + "employment_income": {year: earnings_i}, + } + if flags[i] is not None: + people[f"p{i}"]["is_full_year_category_a_director"] = {year: flags[i]} + return { + "people": people, + "benunits": {f"b{i}": {"members": [p]} for i, p in enumerate(people)}, + "households": {"h": {"members": list(people)}}, + } + + +def test_raw_coverage_and_scale_metadata(): + coverage = load_parameter_file(str(DIRECTORY / "in_effect.yaml"), "in_effect") + for date, expected in [ + ("2022-04-05", False), + ("2022-04-06", True), + ("2023-04-05", True), + ("2023-04-06", False), + ]: + assert coverage(date) == expected + scale = load_parameter_file( + str(DIRECTORY / "employee_scale.yaml"), "employee_scale" + ) + assert scale.metadata["threshold_unit"] == "currency-GBP" + assert scale.metadata["rate_unit"] == "/1" + assert scale.metadata["reference"] + assert not scale.metadata.get("uprating") + for bracket, threshold, rate in zip( + scale.brackets, [0, 11908, 50270], [0, 0.1273, 0.0273] + ): + assert bracket.threshold("2022-04-06") == threshold + assert bracket.rate("2022-04-06") == rate + for leaf in (bracket.threshold, bracket.rate): + assert [v.instant_str for v in leaf.values_list] == ["2022-04-06"] + assert not leaf.metadata.get("uprating") + + +def test_processed_gate_and_input_metadata(): + system = CountryTaxBenefitSystem() + for year, active in [(2021, False), (2022, True), (2023, False), (2030, False)]: + node = system.parameters( + str(year) + ).gov.hmrc.national_insurance.class_1.directors + assert node.full_year_category_a_2022_23.in_effect == active + scale = system.parameters("2022").gov.hmrc.national_insurance.class_1.directors + assert scale.full_year_category_a_2022_23.employee_scale.thresholds == [ + 0, + 11908, + 50270, + ] + assert scale.full_year_category_a_2022_23.employee_scale.rates == [ + 0, + 0.1273, + 0.0273, + ] + variable = system.variables["is_full_year_category_a_director"] + assert variable.default_value is False + assert variable.value_type is bool + assert "2022–23 only" in variable.label + + +@pytest.mark.parametrize("year", [2021, 2022, 2023, 2026]) +def test_default_false_and_out_of_year_true_preserve_ordinary(year): + baseline = Simulation(situation=situation(year, (None,))) + false = Simulation(situation=situation(year, (False,))) + true = Simulation(situation=situation(year, (True,))) + for variable in (*COMPONENTS, "ni_class_1_employer"): + expected = baseline.calculate(variable, year) + np.testing.assert_array_equal(false.calculate(variable, year), expected) + if year != 2022 or variable == "ni_class_1_employer": + np.testing.assert_array_equal(true.calculate(variable, year), expected) + + +def test_monthly_allocation_components_person_mask_and_query_order(): + data = situation(flags=(True, True, False), earnings=(30000, 60000, 30000)) + first = Simulation(situation=data) + second = Simulation(situation=data) + expected = [2303.1116, 5149.1116, 2665.9] + # Query the final total first, then reverse the component query order. + np.testing.assert_allclose( + first.calculate(COMPONENTS[2], 2022), expected, atol=0.01 + ) + additional = second.calculate(COMPONENTS[1], 2022) + primary = second.calculate(COMPONENTS[0], 2022) + np.testing.assert_allclose(primary + additional, expected, atol=0.01) + np.testing.assert_allclose( + second.calculate(COMPONENTS[2], 2022), expected, atol=0.01 + ) + for variable in COMPONENTS: + months = [ + first.calculate(variable, f"2022-{month:02d}") for month in range(1, 13) + ] + np.testing.assert_allclose( + sum(months), first.calculate(variable, 2022), atol=0.01 + ) + # Equal allocations follow annual inputs; they are not observed payroll cash. + for month in months[1:]: + np.testing.assert_array_equal(month, months[0]) + + +def test_previous_year_fact_does_not_extend_implementation_coverage(): + data = situation() + data["people"]["p0"]["age"][2023] = 41 + data["people"]["p0"]["employment_income"][2023] = 30000 + sim = Simulation(situation=data) + baseline = Simulation(situation=situation(2023, (None,))) + np.testing.assert_array_equal( + sim.calculate("ni_class_1_employee", 2023), + baseline.calculate("ni_class_1_employee", 2023), + ) + + +@pytest.mark.parametrize("age", [15, 80]) +def test_existing_final_age_gate_remains_authoritative(age): + # Inconsistent declared status deliberately tests composition, not entitlement. + sim = Simulation(situation=situation(age=age)) + assert sim.calculate("ni_class_1_employee", 2022)[0] == 0 + + +def test_ni_earnings_composition_is_not_replaced_with_salary(): + data = situation(earnings=(28000,)) + data["people"]["p0"].update( + statutory_sick_pay={2022: 2000}, + employee_pension_contributions_reported={2022: 1000}, + ) + sim = Simulation(situation=data) + assert sim.calculate("ni_class_1_income", 2022)[0] == 30000 + assert sim.calculate("ni_class_1_employee", 2022)[0] == pytest.approx( + 2303.1116, abs=0.01 + ) + + +def test_explicit_final_input_remains_authoritative(): + data = situation() + data["people"]["p0"]["ni_class_1_employee"] = {2022: 123} + sim = Simulation(situation=data) + assert sim.calculate("ni_class_1_employee", 2022)[0] == pytest.approx(123, abs=0.01) + + +def test_director_scale_reform_changes_only_the_declared_route(): + data = situation(flags=(True, False), earnings=(30000, 30000)) + + def reform_schedule(simulation): + system = simulation.tax_benefit_system + system.reset_parameters() + scale = system.parameters.gov.hmrc.national_insurance.class_1.directors + scale.full_year_category_a_2022_23.employee_scale.brackets[1].rate.update( + period="year:2022-01-01:1", value=0.2 + ) + system.process_parameters() + + sim = Simulation( + situation=data, + scenario=Scenario( + simulation_modifier=reform_schedule, applied_before_data_load=True + ), + ) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employee", 2022), [3618.4, 2665.9], atol=0.01 + ) + + +def test_natural_cash_support_is_unchanged_by_director_status(): + # This is the existing model's calendar-2022 support, not a new rate rule. + values = [] + other_taxes = [] + for flag, ni in [(False, 2665.9), (True, 2303.1116)]: + data = situation(flags=(flag,)) + data["benunits"]["b0"]["would_claim_uc"] = {2022: False} + data["households"]["h"]["region"] = {2022: "LONDON"} + data["households"]["h"]["household_owns_tv"] = {2022: False} + sim = Simulation(situation=data) + assert sim.calculate("ebr_council_tax_rebate", 2022)[0] == 150 + assert sim.calculate("ebr_energy_bills_credit", 2022)[0] == 199 + assert sim.calculate("household_benefits", 2022)[0] == 349 + assert sim.calculate("tv_licence", 2022)[0] == 0 + assert sim.calculate("income_tax", 2022)[0] == 3486 + assert sim.calculate("national_insurance", 2022)[0] == pytest.approx( + ni, abs=0.01 + ) + actual_cash = sim.calculate("household_net_income", 2022)[0] + other_taxes.append(float(sim.calculate("household_tax", 2022)[0]) - ni) + values.append(float(actual_cash)) + # Only employee NI changes: the literal cash gain equals the NI reduction. + # Tolerance reflects float32 household totals, without a payroll rounding rule. + assert values[1] - values[0] == pytest.approx(362.7884, abs=0.01) + assert other_taxes[0] == pytest.approx(other_taxes[1], abs=0.01) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py index 6f8dffd2c2..ef7b99db7f 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py @@ -16,4 +16,19 @@ def formula(person, period, parameters): parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) - return parameters.rates.employee.additional * upper_earnings_limit_income + ordinary = parameters.rates.employee.additional * upper_earnings_limit_income + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # Match the annual primary component; this is not an interim payroll charge. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_additional = scale.rates[2] * max_( + annual_income - scale.thresholds[2], 0 + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_additional / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py index 69b5db4986..f29602fa40 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py @@ -26,4 +26,20 @@ def formula(person, period, parameters): max_(income - primary_threshold, 0) - upper_earnings_limit_income ) - return parameters.rates.employee.main * primary_threshold_income + ordinary = parameters.rates.employee.main * primary_threshold_income + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # These are annual earnings-period amounts, allocated evenly to model months. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_primary = scale.rates[1] * min_( + max_(annual_income - scale.thresholds[1], 0), + scale.thresholds[2] - scale.thresholds[1], + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_primary / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/input/is_full_year_category_a_director.py b/policyengine_uk/variables/input/is_full_year_category_a_director.py new file mode 100644 index 0000000000..55e57a5b0a --- /dev/null +++ b/policyengine_uk/variables/input/is_full_year_category_a_director.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_full_year_category_a_director(Variable): + label = "Full-year category A director, one employment (employee NI, 2022–23 only)" + documentation = ( + "For employee NI only, explicitly declares directorship throughout the tax year, one employment " + "and category A throughout. Only model year 2022 uses this fact. It does " + "not describe appointment years, other categories or payroll timing." + ) + entity = Person + definition_period = YEAR + value_type = bool + default_value = False + reference = [ + "https://www.gov.uk/employee-directors", + "https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023", + ] From cd065c7d3447d546073ede2a84b42205e84fc911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:12:59 +0100 Subject: [PATCH 053/141] Clarify director NI propagation through the existing Class 4 cap --- .../gov/hmrc/national-insurance-directors.md | 6 +- .../tests/test_ni_full_year_director_2022.py | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/book/programs/gov/hmrc/national-insurance-directors.md b/docs/book/programs/gov/hmrc/national-insurance-directors.md index 2590dde242..b0016e7bcd 100644 --- a/docs/book/programs/gov/hmrc/national-insurance-directors.md +++ b/docs/book/programs/gov/hmrc/national-insurance-directors.md @@ -23,7 +23,11 @@ monetary rounding: (£30,000 − £11,908) × 12.73%. The model retains its exis floating-point representation; this is not a new payroll rounding rule. The director route uses the existing `ni_class_1_income` earnings definition, including its statutory-pay components, and the existing final `ni_liable` -age gate. It does not change employer NI, income tax or other NI classes. +age gate. Employer NI, income-tax and other NI formulas and rates remain +unchanged. Their outputs need not all be unchanged: the existing Class 4 annual +maximum uses primary Class 1 contributions, so it can change for a director +who also has trading profits. That is propagation through the existing cap, +not a new Class 4 rule or a certification of all mixed-income cases. A policy-test input for this restricted case is: diff --git a/policyengine_uk/tests/test_ni_full_year_director_2022.py b/policyengine_uk/tests/test_ni_full_year_director_2022.py index da2c667767..d54b2bd78f 100644 --- a/policyengine_uk/tests/test_ni_full_year_director_2022.py +++ b/policyengine_uk/tests/test_ni_full_year_director_2022.py @@ -215,3 +215,59 @@ def test_natural_cash_support_is_unchanged_by_director_status(): # Tolerance reflects float32 household totals, without a payroll rounding rule. assert values[1] - values[0] == pytest.approx(362.7884, abs=0.01) assert other_taxes[0] == pytest.approx(other_taxes[1], abs=0.01) + + +def test_director_primary_propagates_through_existing_class_4_cap(): + """Composition of existing rules, not a new standalone Class 4 law oracle.""" + results = [] + # Profit is net of £2,000 expenses: gross receipts exceed profit by more + # than the trading allowance. No loss, capital allowance or output is supplied. + for flag, primary, cap, total_ni in [ + (False, 2665.9, 2082.6041179856, 4912.3041179856), + (True, 2303.1116, 2343.6029669065, 4810.5145669065), + ]: + data = situation(flags=(flag,)) + data["people"]["p0"].update( + self_employment_income={2022: 60000}, + self_employment_gross_receipts={2022: 62000}, + ) + data["benunits"]["b0"]["would_claim_uc"] = {2022: False} + data["households"]["h"].update( + region={2022: "LONDON"}, household_owns_tv={2022: False} + ) + sim = Simulation(situation=data) + assert sim.calculate("trading_allowance_deduction", 2022)[0] == 0 + assert sim.calculate("ni_class_4_profits", 2022)[0] == 60000 + assert sim.calculate("ni_class_1_employee_primary", 2022)[0] == pytest.approx( + primary, abs=0.01 + ) + assert sim.calculate("ni_class_1_employee_additional", 2022)[0] == 0 + assert sim.calculate("ni_class_2", 2022)[0] == pytest.approx(163.8, abs=0.01) + # Existing 2022 parameters: UPL50270, LPL11908, Class4rates.0973/.0273, + # Class2weekly3.15. AboveUPL, case1false. Headroom =38362*.0973 + # +53*3.15 -52*3.15 -primary. Remaining main band is charged at.0273. + assert sim.calculate("ni_class_4_maximum", 2022)[0] == pytest.approx( + cap, abs=0.01 + ) + assert cap < 3998.2516 # Uncapped Class 4:38362*.0973+9730*.0273. + assert sim.calculate("ni_class_4", 2022)[0] == pytest.approx(cap, abs=0.01) + assert sim.calculate("national_insurance", 2022)[0] == pytest.approx( + total_ni, abs=0.01 + ) + results.append( + { + variable: float(sim.calculate(variable, 2022)[0]) + for variable in [ + "household_net_income", + "household_benefits", + "income_tax", + "ni_class_1_employer", + ] + } + ) + for variable in ["household_benefits", "income_tax", "ni_class_1_employer"]: + assert results[0][variable] == pytest.approx(results[1][variable], abs=0.01) + # Class 1 falls362.7884, Class4rises260.9988489; final cash gain is101.7895511. + assert results[1]["household_net_income"] - results[0][ + "household_net_income" + ] == pytest.approx(101.7895511, abs=0.01) From 54b9f733f62dd68cf0a70fc1b57b1090f6039f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:02:36 +0100 Subject: [PATCH 054/141] Fix issues from review: restore director NI source references --- .../gov/hmrc/national-insurance-directors.md | 17 +++++++++++++---- .../employee_scale.yaml | 2 ++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/hmrc/national-insurance-directors.md b/docs/book/programs/gov/hmrc/national-insurance-directors.md index b0016e7bcd..0eb5db5cbf 100644 --- a/docs/book/programs/gov/hmrc/national-insurance-directors.md +++ b/docs/book/programs/gov/hmrc/national-insurance-directors.md @@ -10,6 +10,10 @@ the model does not infer it from salary, dividends or company ownership. The input label names its supported 2022–23 period. In other model years, even an explicit `True` leaves the ordinary calculation unchanged. +[Regulation 8(3) of the Social Security (Contributions) Regulations +2001][reg8] sets the earnings period to the year for a director of the +company at the beginning of that year. + HMRC's [2022–23 employer guidance][rates] gives this annual director schedule: | Annual NI earnings | Employee rate | @@ -82,10 +86,15 @@ override contract; the new fact does not validate that supplied amount. This is a partial response to [#721](https://github.com/PolicyEngine/policyengine-uk/issues/721), which remains open. It introduces no director imputation, population estimate or -encoded policy-parity claim. Integration with the separate NI scale migration -in [PR #2490](https://github.com/PolicyEngine/policyengine-uk/pull/2490) requires -coordination of the two employee component files; tests on this branch alone -do not establish that combined-tree compatibility. +encoded policy-parity claim. + +## References + +- [Social Security (Contributions) Regulations 2001, regulation 8][reg8] — earnings periods for directors. +- [Social Security Contributions and Benefits Act 1992, section 8](https://www.legislation.gov.uk/ukpga/1992/4/section/8) — calculation of primary Class 1 contributions. +- [HMRC NIM12000 — Class 1 NICs: company directors](https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000). +- [HMRC CA44 — National Insurance for company directors](https://www.gov.uk/government/publications/ca44-national-insurance-for-company-directors). [rates]: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 [directors]: https://www.gov.uk/employee-directors +[reg8]: https://www.legislation.gov.uk/uksi/2001/1004/regulation/8 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml index d1e3e0e5e2..81fb7cd75f 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml @@ -19,3 +19,5 @@ metadata: reference: - title: HMRC rates and thresholds for employers 2022 to 2023, annual director primary thresholds and category A rates href: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 + - title: Social Security (Contributions) Regulations 2001, regulation 8(3), annual earnings period + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/8 From 5bed5996998eefba4571e8f35162df28ea1a7b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:15:50 +0100 Subject: [PATCH 055/141] Deduct allowable employment expenses from UC earnings --- changelog.d/1997.fixed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 13 +- .../income/uc_employment_expenses.yaml | 61 ++++ .../tests/test_uc_employment_expenses.py | 320 ++++++++++++++++++ .../uc_individual_earned_income_before_mif.py | 14 +- .../deductions/employment_expenses.py | 26 +- 6 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1997.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml create mode 100644 policyengine_uk/tests/test_uc_employment_expenses.py diff --git a/changelog.d/1997.fixed.md b/changelog.d/1997.fixed.md new file mode 100644 index 0000000000..7373ca3370 --- /dev/null +++ b/changelog.d/1997.fixed.md @@ -0,0 +1 @@ +Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 5a6a07caff..06634b319e 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -38,7 +38,18 @@ "* Limited Capability for Work and Work-Related Activity amount\n", "* Carer amount\n", "\n", - "The table below shows some of the rates covered by PolicyEngine-UK. " + "The table below shows some of the rates covered by PolicyEngine-UK. ", + "\n", + "\n", + "## Allowable employment expenses\n", + "\n", + "The `employment_expenses` input is the annual amount already allowed under [Chapter 2 of Part 5 of ITEPA](https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2). Universal Credit disregards this amount from each person's employed earnings before the minimum income floor, under [GB regulation 55(3)(a)](https://www.legislation.gov.uk/uksi/2013/376/regulation/55) and the corresponding [Northern Ireland regulation 55(3)(a)](https://www.legislation.gov.uk/nisr/2016/216/regulation/55). The existing income-tax expense deduction also uses this input.\n", + "\n", + "Supply the legally allowed amount after applicable limits for the relevant employment and other deductions; do not supply all raw work costs. Expenses paid on an employee's behalf and reimbursements must meet sections 333 and 334, including the required inclusion in gross earnings. The same costs must not be deducted twice. The person-level annual input does not determine admissibility, job-by-job limits, or special-case exceptions. No aggregate salary cap or new earnings floor is inferred from it.\n", + "\n", + "For example, in the existing 2024 annual model, a single claimant aged 30 with £6,000 employment income and £600 of allowable expenses has £5,400 UC earnings before the minimum income floor. With no tax, NI, pension deductions, work allowance or other award adjustments, the 55% taper increases final UC by £330 relative to the same worker with no expenses. This illustrates the UC award; it does not certify household net-income treatment of the cost itself.\n", + "\n", + "This partial correction leaves payroll giving and other unresolved parts of [issue 1997](https://github.com/PolicyEngine/policyengine-uk/issues/1997) open. It retains the existing annual allocation, pension/trade calculation, minimum income floor, claimant selection and direct-input overrides. It does not establish assessment-period expense/payment timing, special-category admissibility, historical commencement or complete GB/NI history.\n" ] }, { diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml new file mode 100644 index 0000000000..dc6bd9b846 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml @@ -0,0 +1,61 @@ +# The expense input is an already-allowable ITEPA Part 5 Chapter 2 amount. +# No raw-cost or per-employment admissibility is inferred by these cases. +- name: Qualified employment expenses reduce earnings and increase final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + employment_expenses: 600 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 5_400 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 5_400 + uc_earned_income: 5_400 + uc_income_reduction: 2_970 + # Standard allowance 393.45 monthly; no child/housing elements, + # benefit cap reduction or debt deductions. + universal_credit_pre_benefit_cap: 1_751.40 + universal_credit: 1_751.40 + +- name: The same worker without expenses retains the original final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 6_000 + income_tax: 0 + national_insurance: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 6_000 + uc_earned_income: 6_000 + uc_income_reduction: 3_300 + universal_credit_pre_benefit_cap: 1_421.40 + universal_credit: 1_421.40 diff --git a/policyengine_uk/tests/test_uc_employment_expenses.py b/policyengine_uk/tests/test_uc_employment_expenses.py new file mode 100644 index 0000000000..f27c0cb397 --- /dev/null +++ b/policyengine_uk/tests/test_uc_employment_expenses.py @@ -0,0 +1,320 @@ +"""UC expense disregards use already-allowable amounts, not raw-cost claims.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEAR = 2024 + + +def situation(people=None, *, year=YEAR, benunit=None): + people = people or {"claimant": {"age": 30, "employment_income": 6_000}} + names = list(people) + return { + "people": { + name: {key: {year: value} for key, value in inputs.items()} + for name, inputs in people.items() + }, + "benunits": { + "family": { + "members": names, + "would_claim_uc": {year: True}, + **{key: {year: value} for key, value in (benunit or {}).items()}, + } + }, + "households": { + "household": { + "members": names, + "region": {year: "LONDON"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + }, + } + + +def simulation(people=None, *, year=YEAR, benunit=None): + return Simulation(situation=situation(people, year=year, benunit=benunit)) + + +def test_allowable_expenses_increase_natural_final_uc_cash(): + ordinary = simulation() + expenses = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + for sim, earned in [(ordinary, 6_000), (expenses, 5_400)]: + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [earned], + atol=0.01, + ) + assert sim.calculate("is_uc_eligible", YEAR)[0] + assert sim.calculate("claims_universal_credit", YEAR)[0] + assert not sim.calculate("uc_mif_applies", YEAR)[0] + for variable in [ + "income_tax", + "national_insurance", + "pension_contributions", + "uc_work_allowance", + "uc_benefit_cap_reduction", + "uc_deductions", + ]: + np.testing.assert_allclose(sim.calculate(variable, YEAR), [0], atol=0.01) + # Both awards are positive and below the unchanged maximum: the + # 55% earnings taper passes on 600 * .55 = 330, without clipping. + for variable in ["universal_credit_pre_benefit_cap", "universal_credit"]: + low = ordinary.calculate(variable, YEAR) + high = expenses.calculate(variable, YEAR) + assert low[0] > 0 + assert high[0] < expenses.calculate("uc_maximum_amount", YEAR)[0] + np.testing.assert_allclose(high - low, [330], atol=0.01) + + +@pytest.mark.parametrize("year", [2020, 2024, 2026]) +def test_zero_expenses_preserve_the_default(year): + default = simulation(year=year) + zero = simulation( + {"claimant": {"age": 30, "employment_income": 6_000, "employment_expenses": 0}}, + year=year, + ) + for variable in [ + "uc_individual_earned_income_before_mif", + "uc_earned_income", + "universal_credit", + ]: + np.testing.assert_array_equal( + default.calculate(variable, year), zero.calculate(variable, year) + ) + + +@pytest.mark.parametrize("expenses, expected", [(600, 5_400), (6_000, 0)]) +def test_allowed_deduction_and_exact_earnings_limit(expenses, expected): + # The supplied amount has already satisfied per-employment legal limits. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expenses, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [expected], atol=0.01 + ) + + +def test_each_claimants_deduction_is_used_once(): + sim = simulation( + { + "claimant": { + "age": 40, + "employment_income": 6_000, + "employment_expenses": 600, + }, + "partner": { + "age": 38, + "employment_income": 4_000, + "employment_expenses": 400, + }, + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400, 3_600], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), [9_000], atol=0.01 + ) + + +def test_nonclaimants_expense_does_not_reduce_claimants_earnings(): + sim = simulation( + { + "parent": {"age": 40, "is_parent": True, "employment_income": 10_000}, + "child": { + "age": 17, + "current_education": "UPPER_SECONDARY", + "employment_income": 3_000, + "employment_expenses": 600, + }, + } + ) + np.testing.assert_array_equal( + sim.calculate("is_uc_assessed_claimant", YEAR), [True, False] + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [10_000, 2_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), + 10_000 - sim.calculate("uc_work_allowance", YEAR), + atol=0.01, + ) + + +@pytest.mark.parametrize("trade, expected", [(2_000, 7_400), (-2_000, 5_400)]) +def test_trade_earnings_keep_their_separate_nil_floor(trade, expected): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "self_employment_income": trade, + "uc_is_in_startup_period": True, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + + +def test_mif_still_compares_with_expense_net_actual_earnings(): + # Supplied MIF statuses/threshold are composition controls, not a + # natural claim that every worker is subject to the floor. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "uc_mif_applies": True, + "uc_minimum_income_floor": 8_000, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income", YEAR), [8_000], atol=0.01 + ) + + +def test_annual_flow_is_allocated_to_month_without_changing_input(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + np.testing.assert_allclose( + sim.calculate("employment_expenses", "2024-04"), [50], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", "2024-04"), + [450], + atol=0.01, + ) + + +def test_direct_final_uc_input_remains_an_override(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + }, + benunit={"universal_credit": 1_234}, + ) + np.testing.assert_allclose( + sim.calculate("universal_credit", YEAR), [1_234], atol=0.01 + ) + + +def test_multiunit_vector_keeps_expenses_with_their_owner(): + units = [ + situation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expense, + } + } + ) + for expense in [0, 600] + ] + merged = {entity: {} for entity in ["people", "benunits", "households"]} + for index, unit in enumerate(units): + for entity, rows in unit.items(): + for name, row in rows.items(): + row = dict(row) + if "members" in row: + row["members"] = [f"{member}_{index}" for member in row["members"]] + merged[entity][f"{name}_{index}"] = row + sim = Simulation(situation=merged) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_000, 5_400], + atol=0.01, + ) + + +def test_statutory_pay_and_pension_components_are_retained(): + # The pension and statutory-pay inputs exercise existing composition, + # with qualified expenses below the worker's employment earnings. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "statutory_sick_pay": 1_000, + "pension_contributions": 100, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_300], + atol=0.01, + ) + + +def test_only_the_specific_expense_input_is_disregarded(): + # Supplied aggregate override: this checks consumer routing, not + # statutory admissibility of a separate class of deduction. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "employment_deductions": 2_500, + } + } + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [3_500], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index f0abee8de3..ac9f2e484c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -10,7 +10,7 @@ class uc_individual_earned_income_before_mif(Variable): documentation = ( "The person's actual earned income for Universal Credit: their " "earnings, including statutory sick, maternity and paternity pay, " - "less their own relievable pension contributions and their own " + "less allowable employment expenses, their own relievable pension contributions and their own " "income tax and National Insurance in respect of their employment " "and self-employment. Concurrent trade results include company " "income treated as self-employed earnings under regulation 77; " @@ -23,6 +23,14 @@ class uc_individual_earned_income_before_mif(Variable): definition_period = YEAR unit = GBP reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(3)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations (Northern Ireland) 2016 reg. 55(3)(a)", + href="https://www.legislation.gov.uk/nisr/2016/216/regulation/55", + ), dict( title="Universal Credit Regulations 2013 reg. 55(4) and (5)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", @@ -92,8 +100,12 @@ def formula(person, period, parameters): + person("uc_national_insurance_on_earnings", period) - person("uc_national_insurance_on_trade", period) ) + # Reg. 55(3)(a) disregards expenses allowed under Chapter 2 of + # Part 5 ITEPA. This input is the already-allowable amount, after + # relevant-employment limits, not a claim for arbitrary raw costs. employed = ( add(person, period, earnings_components) + - person("employment_expenses", period) - employment_tax_and_national_insurance ) # Contributions paid after the person reaches 75 are not relievable diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py index cffac78c1b..77b4b95b23 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py @@ -4,10 +4,26 @@ class employment_expenses(Variable): value_type = float entity = Person - label = "Cost of expenses necessarily incurred and reimbursed by employment" - definition_period = YEAR - reference = dict( - title="Income Tax Act (Earnings and Pensions) Act 2003 s. 333", - href="https://www.legislation.gov.uk/ukpga/2003/1/section/333", + label = "Allowable employment expenses" + documentation = ( + "Employment expenses allowed as a deduction under Chapter 2 of Part 5 " + "of ITEPA, after the applicable relevant-employment and other deduction " + "limits. Supply the legally allowed amount, not all raw work costs. " + "Amounts paid on the employee's behalf, and reimbursements, qualify " + "only to the extent required by sections 333 and 334, including their " + "inclusion in earnings. Do not deduct the same costs twice. The model " + "does not determine admissibility or per-employment limits from this " + "person-level total." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 333 and 334", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2", + ), + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 328 to 330", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/1", + ), + ] unit = GBP From d08f26ed4a36fd8836a8c1fb25387854aded9e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:09:54 +0100 Subject: [PATCH 056/141] Fix issues from review: clarify UC expense scope and references --- changelog.d/1997.fixed.md | 2 ++ docs/book/programs/gov/dwp/universal-credit.ipynb | 5 +++-- .../income/uc_individual_earned_income_before_mif.py | 6 +----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/changelog.d/1997.fixed.md b/changelog.d/1997.fixed.md index 7373ca3370..bcc1b4fd30 100644 --- a/changelog.d/1997.fixed.md +++ b/changelog.d/1997.fixed.md @@ -1 +1,3 @@ Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. + +Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 06634b319e..c254e52dc8 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -38,8 +38,7 @@ "* Limited Capability for Work and Work-Related Activity amount\n", "* Carer amount\n", "\n", - "The table below shows some of the rates covered by PolicyEngine-UK. ", - "\n", + "The table below shows some of the rates covered by PolicyEngine-UK. \n", "\n", "## Allowable employment expenses\n", "\n", @@ -49,6 +48,8 @@ "\n", "For example, in the existing 2024 annual model, a single claimant aged 30 with £6,000 employment income and £600 of allowable expenses has £5,400 UC earnings before the minimum income floor. With no tax, NI, pension deductions, work allowance or other award adjustments, the 55% taper increases final UC by £330 relative to the same worker with no expenses. This illustrates the UC award; it does not certify household net-income treatment of the cost itself.\n", "\n", + "Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified.\n", + "\n", "This partial correction leaves payroll giving and other unresolved parts of [issue 1997](https://github.com/PolicyEngine/policyengine-uk/issues/1997) open. It retains the existing annual allocation, pension/trade calculation, minimum income floor, claimant selection and direct-input overrides. It does not establish assessment-period expense/payment timing, special-category admissibility, historical commencement or complete GB/NI history.\n" ] }, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index ac9f2e484c..a17fc4c791 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -24,17 +24,13 @@ class uc_individual_earned_income_before_mif(Variable): unit = GBP reference = [ dict( - title="Universal Credit Regulations 2013 reg. 55(3)(a)", + title="Universal Credit Regulations 2013 reg. 55(3)(a), (4) and (5)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", ), dict( title="Universal Credit Regulations (Northern Ireland) 2016 reg. 55(3)(a)", href="https://www.legislation.gov.uk/nisr/2016/216/regulation/55", ), - dict( - title="Universal Credit Regulations 2013 reg. 55(4) and (5)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", - ), dict( title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", From 508fbcc42806abebe423c660eb4b4f79fc48c930 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 14:46:54 +0200 Subject: [PATCH 057/141] Set the CGT annual exempt amount pound by pound where it saves most The one-off ranking of schedules by the rate their top gains reach kept exempting a schedule after its top-rate slice was used up. The exemption is now allocated in passes: each puts it against the schedule where a pound saves most tax at the margin, including any band that removing it frees for another schedule, and stops where a schedule's gains cross a band boundary (TCGA 1992 s. 1K(5)). Adds the 124,000 earnings / carried interest boundary case from review as a YAML and a pytest regression. Co-Authored-By: Claude Opus 5.5 --- .../cgt-additional-band-stacking.fixed.md | 2 +- .../capital_gains_tax_rate_bands.yaml | 21 +++ .../test_capital_gains_tax_components.py | 35 ++++- .../capital_gains_tax/capital_gains_tax.py | 123 +++++++++++++----- 4 files changed, 145 insertions(+), 36 deletions(-) diff --git a/changelog.d/cgt-additional-band-stacking.fixed.md b/changelog.d/cgt-additional-band-stacking.fixed.md index fea6956098..c14c40830f 100644 --- a/changelog.d/cgt-additional-band-stacking.fixed.md +++ b/changelog.d/cgt-additional-band-stacking.fixed.md @@ -1 +1 @@ -Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band. +Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band, and set the annual exempt amount against the gains where each pound saves most tax. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml index 26de1e9e26..9bc38acd25 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml @@ -153,3 +153,24 @@ capital_gains_residential_property: 25_000 output: capital_gains_tax: 15_900 + +- name: Additional rate reform, exemption follows the rate as main gains cross the higher rate limit. + # The personal allowance is 12,570 - (124,000 - 100,000) / 2 = 570, so + # taxable income is 123,430 and 125,140 - 123,430 = 1,710 of higher band is + # left. Main gains fill it first: 1,710 of the 2,000 main gains at 24% and + # 290 at the reformed 45%. Carried interest is at 32% in either band. The + # exemption saves 45% on the top 290 of main gains, then 32% on carried + # interest rather than 24% on the rest of the main gains (TCGA 1992 + # s. 1K(5)): 1,710 x 0.24 + (25,000 - 2,710) x 0.32 = 7,543.20, not 7,680 + # from exempting all the main gains first. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + gov.simulation.capital_gains_responses.elasticity: 0 + age: 45 + employment_income: 124_000 + capital_gains: 27_000 + capital_gains_carried_interest: 25_000 + output: + capital_gains_tax: 7_543.20 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index ff3a08a897..a74387ba6e 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -350,8 +350,9 @@ class TestExemptionAllocation: """The annual exempt amount goes where it saves most (TCGA 1992 s. 1K(5)). Gains stack on taxable income, so a schedule's top gains can be at its - additional rate. The order of the exemption is set for each person from - the rate their top gains are actually charged at. + additional rate. The exemption is set for each person, pound by pound, + against the gains where it saves most, so it moves to another schedule + once a schedule's gains fall to a lower rate. """ YEAR = 2025 @@ -393,6 +394,36 @@ def test_exemption_follows_each_persons_top_rate(self): # 47,000 x 0.24, wherever the exemption goes. assert tax[1] == pytest.approx(11_280) + def test_exemption_moves_on_when_the_marginal_rate_falls(self): + sim = Microsimulation( + situation={ + "people": { + "person": { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: 124_000}, + "capital_gains": {self.YEAR: 27_000}, + "capital_gains_carried_interest": {self.YEAR: 25_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {self.YEAR: "LONDON"}, + } + }, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 123,430 leaves 1,710 of higher band, so 290 of the + # 2,000 main gains are at 45% and the rest at 24%; carried interest + # is at 32%. The exemption takes the 290 at 45%, then 2,710 of + # carried interest: 1,710 x 0.24 + 22,290 x 0.32 = 7,543.20, not + # 7,680 from exempting all the main gains first. + assert tax[0] == pytest.approx(7_543.20) + class TestScheduleParameters: """Dates and values of the new gov.hmrc.cgt parameters by fiscal year.""" diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index 37b1db91d3..3a6d276474 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -17,6 +17,12 @@ def _take(amount, band_left): return inside, above, band_left - inside +# Each pass of the exemption allocation ends where a schedule's gains cross a +# band boundary, so a handful is enough; four schedules in three bands give at +# most twelve boundaries. +_MAX_EXEMPTION_PASSES = 16 + + class capital_gains_tax(Variable): label = "capital gains tax" documentation = ( @@ -31,8 +37,8 @@ class capital_gains_tax(Variable): "other two in proportion with the rest of the gains. All three are " "clipped so that together they never exceed capital_gains. The annual " "exempt amount is set " - "against the schedule whose top gains are charged at the highest rate " - "first (TCGA 1992 s. 1K(5)); the unused basic rate band goes to relief gains first, as " + "against the gains where it saves most tax, pound by pound " + "(TCGA 1992 s. 1K(5)); the unused basic rate band goes to relief gains first, as " "s. 1I(4)-(6) requires, and then to the schedule with the widest gap " "between its higher and basic rates (s. 1I(7)-(9)). Gains above the " "basic rate band are split at the income tax higher rate limit only so " @@ -174,40 +180,91 @@ def allocate_bands(schedule_amounts): return inside_basic, inside_higher, above_higher # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most - # beneficial to the taxpayer, which is against the schedule whose top - # gains are charged at the highest rate first. A schedule's top gains - # are at its additional rate when, before the exemption, any of them - # reach above the higher rate band, which happens whenever taxable - # income is already above the higher rate limit; otherwise they are - # at its higher rate. Relief gains carry their own flat rate. The - # order is set for each person, and ties keep declaration order. - _, _, above_higher_before_aea = allocate_bands(amounts) + # beneficial to the taxpayer. Each pound saves the tax it would have + # borne at the margin, which depends on the schedule it comes off and, + # where removing it frees band for another schedule, on that schedule + # too. So the exemption is set greedily: in each pass, against the + # schedule where a pound saves most, up to the point where any + # schedule's gains cross a band boundary and the saving can change. + # Ties keep declaration order. Under current law, where the higher and + # additional rates are equal, this gives the same allocation as + # ranking the schedules by rate, apart from float rounding where the + # exemption is split across schedules. names = list(amounts) - top_rate = np.stack( - [ - ( - np.full_like(amounts[name], cgt.badr.rate, dtype=float) - if name == "badr" - else where( - above_higher_before_aea[name] > 0, - rates[name].additional_rate, + flat_rate = {"badr": cgt.badr.rate} + + def slices(schedule_amounts): + inside_basic, inside_higher, above_higher = allocate_bands(schedule_amounts) + result = [] + for name in names: + if name in flat_rate: + band_rates = [flat_rate[name]] * 3 + else: + band_rates = [ + rates[name].basic_rate, rates[name].higher_rate, + rates[name].additional_rate, + ] + parts = [inside_basic[name], inside_higher[name], above_higher[name]] + result += list(zip(parts, band_rates)) + return result + + left = {name: amounts[name].astype(float) for name in names} + used = {name: np.zeros_like(left[name]) for name in names} + aea_left = np.full_like(left["main"], aea) + step = 1e-6 + for _ in range(_MAX_EXEMPTION_PASSES): + if not np.any(aea_left > 0): + break + base = slices(left) + savings, room = [], [] + for name in names: + nudged = dict(left) + nudged[name] = left[name] - step + saving = np.zeros_like(aea_left) + distance = np.full_like(aea_left, np.inf) + for (part, rate), (moved, _) in zip(base, slices(nudged)): + slope = np.round((moved - part) / step) + saving = saving - slope * rate + shrinking = (slope < 0) & (part > 0) + distance = where( + shrinking, + min_(distance, part / where(shrinking, -slope, 1)), + distance, ) - ) - for name in names - ] - ) - stacked = np.stack([amounts[name] for name in names]) - order = np.argsort(-top_rate, axis=0, kind="stable") - stacked_after_aea = np.empty_like(stacked) - aea_left = aea - for position in range(len(names)): - index = order[position : position + 1] - amount = np.take_along_axis(stacked, index, axis=0)[0] - used = min_(amount, aea_left) - np.put_along_axis(stacked_after_aea, index, (amount - used)[None], axis=0) - aea_left = aea_left - used - after_aea = {name: stacked_after_aea[i] for i, name in enumerate(names)} + active = (left[name] > 0) & (aea_left > 0) + savings.append(where(active, np.round(saving, 9), -np.inf)) + room.append(min_(left[name], distance)) + savings = np.stack(savings) + best = np.argmax(savings, axis=0) + can_take = np.max(savings, axis=0) > -np.inf + for i, name in enumerate(names): + taken = where(can_take & (best == i), min_(aea_left, room[i]), 0) + left[name] = left[name] - taken + used[name] = used[name] + taken + aea_left = aea_left - taken + # Any exemption left after the passes, which only happens when gains + # cross more band boundaries than there are passes, goes in + # declaration order. + for name in names: + taken = min_(aea_left, left[name]) + used[name] = used[name] + taken + aea_left = aea_left - taken + # Where the whole exemption went against one schedule, deduct it in a + # single step so that the passes add no rounding of their own. + schedules_used = sum((used[name] > 0).astype(int) for name in names) + after_aea = { + name: where( + schedules_used <= 1, + where( + used[name] > 0, + amounts[name] - min_(amounts[name], aea), + amounts[name], + ), + amounts[name] - used[name], + ).astype(amounts[name].dtype) + for name in names + } inside_basic, inside_higher, above_higher = allocate_bands(after_aea) From c19ce75734e474c6e0ec6794ab765df6a525c077 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 15:54:54 +0200 Subject: [PATCH 058/141] =?UTF-8?q?Separate=20the=20=C2=A33,600=20relief?= =?UTF-8?q?=20deduction=20from=20the=20=C2=A3720=20provider=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-earnings example called the £3,600 pension_contributions_relief the basic-rate top-up. It is a deduction from taxable income. Under relief at source a £3,600 gross contribution is £2,880 paid by the contributor and £720 claimed by the provider from HMRC (FA 2004 s. 192), which the model does not represent as a payment. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/hmrc/pension-tax-relief.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md index 9db4c64e2b..4cb4b40702 100644 --- a/docs/book/programs/gov/hmrc/pension-tax-relief.md +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -27,10 +27,16 @@ by their pension contributions, up to the annual limit for relief in - and only for people under `gov.hmrc.pensions.pension_contributions_relief_age_limit` (75). -So someone with no earnings who pays £3,600 gross into a personal pension -gets £3,600 of relief, the basic-rate top-up paid into their pension under -relief at source, while the same payment as an employee contribution gets -none. Two open pull requests would change this section once merged: +So someone with no earnings who makes a £3,600 gross contribution to a +personal pension gets a £3,600 `pension_contributions_relief`, while the same +payment as an employee contribution gets none. That £3,600 is the deduction +from taxable income, not a cash top-up. Under relief at source the £3,600 +gross contribution is £2,880 paid by the contributor plus £720 (20%) that the +pension provider claims from HMRC and adds to the pension +([Finance Act 2004 s. 192](https://www.legislation.gov.uk/ukpga/2004/12/section/192)). +PolicyEngine does not represent the £720 claim as a payment, and with no +taxable income the deduction saves no income tax. Two open pull requests +would change this section once merged: [#2464](https://github.com/PolicyEngine/policyengine-uk/pull/2464) would add Statutory Sick Pay and Statutory Maternity Pay to relevant earnings, and [#2488](https://github.com/PolicyEngine/policyengine-uk/pull/2488) would apply From 9b00e432c9c5e390c2be51400081bae76fde759d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 16:14:44 +0200 Subject: [PATCH 059/141] Decide PIP categories from points per person One person's points for a year no longer switch everyone's category to points that year. The points inputs now default to -1 (not assessed), so a person without points keeps their carried category, and a person's earlier points carry forward past someone else's later points. Supplied zero points are still an assessment. Covers daily living and mobility, with a mixed-person regression through final PIP for 2025 and 2026. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1069.added.md | 2 +- .../tests/test_pip_points_mixed_people.py | 119 ++++++++++++++++++ policyengine_uk/utils/pip.py | 84 ++++++++----- .../variables/input/pip_dl_category.py | 2 +- .../variables/input/pip_dl_points.py | 4 +- .../variables/input/pip_m_category.py | 2 +- .../variables/input/pip_m_points.py | 4 +- 7 files changed, 177 insertions(+), 40 deletions(-) create mode 100644 policyengine_uk/tests/test_pip_points_mixed_people.py diff --git a/changelog.d/1069.added.md b/changelog.d/1069.added.md index 0634713134..5f3e6d5bfb 100644 --- a/changelog.d/1069.added.md +++ b/changelog.d/1069.added.md @@ -1 +1 @@ -Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from points supplied for the year when the category is not provided as input. Without points for the year, a category supplied for an earlier year still carries forward. +Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from each person's latest points when the category is not provided as input. A person's category supplied for an earlier year still carries forward unless that person has points for a later year. diff --git a/policyengine_uk/tests/test_pip_points_mixed_people.py b/policyengine_uk/tests/test_pip_points_mixed_people.py new file mode 100644 index 0000000000..655b222548 --- /dev/null +++ b/policyengine_uk/tests/test_pip_points_mixed_people.py @@ -0,0 +1,119 @@ +"""PIP categories are decided per person when some people have points. + +One person's points are not evidence about anyone else, so a person given +only an earlier category keeps it in a year when someone else has points. +""" + +import pytest + +from policyengine_uk import Simulation + + +def _two_adults(first_person: dict, second_person: dict) -> dict: + """Two adults aged 30 in separate benefit units and households in London.""" + people = {"a": first_person, "b": second_person} + situation = { + "people": {}, + "benunits": {}, + "households": {}, + } + for name, inputs in people.items(): + situation["people"][name] = {"age": {"2025": 30, "2026": 31}, **inputs} + situation["benunits"][f"benunit_{name}"] = {"members": [name]} + situation["households"][f"household_{name}"] = { + "members": [name], + "region": {"2025": "LONDON", "2026": "LONDON"}, + } + return situation + + +@pytest.mark.parametrize( + "component, points_variable, category_variable, amount_variable, " + "enhanced_2025, enhanced_2026", + [ + ("daily living", "pip_dl_points", "pip_dl_category", "pip", 5_740.80, 5_959.20), + ("mobility", "pip_m_points", "pip_m_category", "pip", 4_006.60, 4_160.00), + ], +) +def test_one_persons_points_keep_anothers_carried_category( + component, + points_variable, + category_variable, + amount_variable, + enhanced_2025, + enhanced_2026, +): + # A is assessed at 12 points in 2026. B has only an enhanced award from + # 2025 and no points in any year. + simulation = Simulation( + situation=_two_adults( + {points_variable: {"2026": 12}}, + {category_variable: {"2025": "ENHANCED"}}, + ) + ) + categories = { + year: list(simulation.calculate(category_variable, year)) + for year in (2025, 2026) + } + assert categories == { + 2025: ["NONE", "ENHANCED"], + 2026: ["ENHANCED", "ENHANCED"], + }, component + assert simulation.calculate(amount_variable, 2025).tolist() == pytest.approx( + [0, enhanced_2025], abs=0.05 + ) + assert simulation.calculate(amount_variable, 2026).tolist() == pytest.approx( + [enhanced_2026, enhanced_2026], abs=0.05 + ) + + +def test_supplied_zero_points_still_replace_an_earlier_category(): + # Zero points are an assessment, unlike points left out. + simulation = Simulation( + situation=_two_adults( + { + "pip_dl_category": {"2025": "ENHANCED"}, + "pip_dl_points": {"2026": 0}, + }, + {"pip_dl_category": {"2025": "ENHANCED"}}, + ) + ) + assert list(simulation.calculate("pip_dl_category", 2026)) == [ + "NONE", + "ENHANCED", + ] + + +def test_a_persons_earlier_points_carry_forward_past_anothers_later_points(): + # B was assessed at 12 points in 2025; A is assessed in 2026 and B is not. + simulation = Simulation( + situation=_two_adults( + {"pip_dl_points": {"2026": 12}}, + {"pip_dl_points": {"2025": 12}}, + ) + ) + assert list(simulation.calculate("pip_dl_category", 2026)) == [ + "ENHANCED", + "ENHANCED", + ] + assert simulation.calculate("pip", 2026).tolist() == pytest.approx( + [5_959.20, 5_959.20], abs=0.05 + ) + + +def test_a_persons_later_points_beat_their_own_earlier_category_only(): + # A had an enhanced award in 2025 and scores 8 points in 2026. B keeps + # their own 2025 standard award. + simulation = Simulation( + situation=_two_adults( + { + "pip_m_category": {"2025": "ENHANCED"}, + "pip_m_points": {"2026": 8}, + }, + {"pip_m_category": {"2025": "STANDARD"}}, + ) + ) + assert list(simulation.calculate("pip_m_category", 2026)) == [ + "STANDARD", + "STANDARD", + ] diff --git a/policyengine_uk/utils/pip.py b/policyengine_uk/utils/pip.py index c66ac41499..e47a904353 100644 --- a/policyengine_uk/utils/pip.py +++ b/policyengine_uk/utils/pip.py @@ -1,5 +1,6 @@ """Shared logic for the PIP component categories.""" +import numpy as np from policyengine_core.periods import Period from policyengine_uk.model_api import select @@ -9,46 +10,63 @@ ) from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory +# The default of the points inputs: the person was not assessed. Zero is an +# assessment that scored no points. +NOT_ASSESSED = -1 + def pip_category(person, period: Period, thresholds, component: str): """The PIP category for ``component`` ("dl" or "m") in ``period``. A category supplied for the period is an input, so this only runs when - there is none. Points supplied for the period are then the evidence. If - they are not, the most recent earlier input decides: a category supplied - in an earlier year carries forward, as it would for an input-only - variable, unless points were supplied in a later year than that - category. Points, like other inputs, carry forward from the latest year - they were supplied. Points default to zero, below the standard - threshold, so with neither input the category is NONE. - - Inputs are stored for the whole population, so "supplied" is decided - per year, not per person. If points are supplied for a year for anyone, - everyone's category that year comes from points, and a person given - only an earlier category reads the default of zero points (NONE). Give - such a person their points or category for that year as well. + there is none. Each person's evidence is then the more recent of their + points and the category supplied for an earlier year, which carries + forward as it would for an input-only variable: + + - a person's points come from the latest year up to ``period`` for + which they were assessed (points other than ``NOT_ASSESSED``); + - they decide the category if they are for a later year than the latest + earlier category, or if there is no earlier category; + - otherwise the earlier category carries forward. With neither, the + category is NONE. + + Points are decided per person: one person's points for a year leave + another person's carried category alone. A category supplied for a year + is stored for the whole population, so it applies to everyone that year, + with NONE for anyone not given one, as before the points existed. """ category_variable = f"pip_{component}_category" points_variable = f"pip_{component}_points" - if supplied_input(person, points_variable, period) is None: - earlier_categories = [ - p - for p in supplied_input_periods(person, category_variable) - if p.start < period.start - ] - earlier_points = [ - p - for p in supplied_input_periods(person, points_variable) - if p.start < period.start - ] - if earlier_categories and ( - not earlier_points - or earlier_categories[-1].start >= earlier_points[-1].start - ): - return supplied_input(person, category_variable, earlier_categories[-1]) - points = person(points_variable, period) - return select( + + earlier_categories = [ + p + for p in supplied_input_periods(person, category_variable) + if p.start < period.start + ] + category_period = earlier_categories[-1] if earlier_categories else None + + points = np.full(person.count, NOT_ASSESSED) + # Whether the person's latest points are for a later year than the + # latest earlier category (always, if there is no such category). + points_are_newer = np.zeros(person.count, dtype=bool) + for points_period in supplied_input_periods(person, points_variable): + if points_period.start > period.start: + break + values = supplied_input(person, points_variable, points_period) + assessed = values != NOT_ASSESSED + points = np.where(assessed, values, points) + points_are_newer = np.where( + assessed, + category_period is None or points_period.start > category_period.start, + points_are_newer, + ) + + from_points = select( [points >= thresholds.enhanced, points >= thresholds.standard], - [PIPCategory.ENHANCED, PIPCategory.STANDARD], - default=PIPCategory.NONE, + [PIPCategory.ENHANCED.index, PIPCategory.STANDARD.index], + default=PIPCategory.NONE.index, ) + if category_period is None: + return from_points + carried = np.asarray(supplied_input(person, category_variable, category_period)) + return np.where(points_are_newer, from_points, carried) diff --git a/policyengine_uk/variables/input/pip_dl_category.py b/policyengine_uk/variables/input/pip_dl_category.py index d9f777e31e..b09f63d022 100644 --- a/policyengine_uk/variables/input/pip_dl_category.py +++ b/policyengine_uk/variables/input/pip_dl_category.py @@ -5,7 +5,7 @@ class pip_dl_category(Variable): label = "PIP (daily living) category" - documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from `pip_dl_points` supplied for the year against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`. Without points for the year, a category supplied for an earlier year carries forward, unless points were supplied for a later year than that category." + documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from the person's latest `pip_dl_points` for this or an earlier year against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`. A category supplied for an earlier year carries forward instead, unless the person has points for a later year than that category." entity = Person definition_period = YEAR value_type = Enum diff --git a/policyengine_uk/variables/input/pip_dl_points.py b/policyengine_uk/variables/input/pip_dl_points.py index d05830015d..175a6253a3 100644 --- a/policyengine_uk/variables/input/pip_dl_points.py +++ b/policyengine_uk/variables/input/pip_dl_points.py @@ -3,8 +3,8 @@ class pip_dl_points(Variable): label = "PIP daily living points" - documentation = "Total descriptor points scored across the ten Schedule 1 Part 2 daily living activities. Used to derive `pip_dl_category` when the category is not provided as input." + documentation = "Total descriptor points scored across the ten Schedule 1 Part 2 daily living activities. Used to derive `pip_dl_category` when the category is not provided as input. The default of -1 means the person was not assessed; 0 is an assessment that scored no points." entity = Person definition_period = YEAR value_type = int - default_value = 0 + default_value = -1 diff --git a/policyengine_uk/variables/input/pip_m_category.py b/policyengine_uk/variables/input/pip_m_category.py index 8c150ef4f7..e6ea92e085 100644 --- a/policyengine_uk/variables/input/pip_m_category.py +++ b/policyengine_uk/variables/input/pip_m_category.py @@ -5,7 +5,7 @@ class pip_m_category(Variable): label = "PIP (mobility) category" - documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from `pip_m_points` supplied for the year against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`. Without points for the year, a category supplied for an earlier year carries forward, unless points were supplied for a later year than that category." + documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from the person's latest `pip_m_points` for this or an earlier year against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`. A category supplied for an earlier year carries forward instead, unless the person has points for a later year than that category." entity = Person definition_period = YEAR value_type = Enum diff --git a/policyengine_uk/variables/input/pip_m_points.py b/policyengine_uk/variables/input/pip_m_points.py index 022670718b..0856575bb1 100644 --- a/policyengine_uk/variables/input/pip_m_points.py +++ b/policyengine_uk/variables/input/pip_m_points.py @@ -3,8 +3,8 @@ class pip_m_points(Variable): label = "PIP mobility points" - documentation = "Total descriptor points scored across the two Schedule 1 Part 3 mobility activities. Used to derive `pip_m_category` when the category is not provided as input." + documentation = "Total descriptor points scored across the two Schedule 1 Part 3 mobility activities. Used to derive `pip_m_category` when the category is not provided as input. The default of -1 means the person was not assessed; 0 is an assessment that scored no points." entity = Person definition_period = YEAR value_type = int - default_value = 0 + default_value = -1 From a577ebaf66e01e2d32804531373f3611107acf5f Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 17:28:51 +0200 Subject: [PATCH 060/141] Keep Northern Ireland LHA rates off Great Britain freeze and percentile parameters Add gov.dfc.LHA.freeze and gov.dfc.LHA.percentile for the Northern Ireland Housing Executive's determinations, with values matching the Great Britain parameters, and splice Northern Ireland BRMAs' rates from them in lha_rates. Fixes #2502 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2502.fixed.md | 1 + .../parameters/gov/dfc/LHA/freeze.yaml | 37 +++++++++++ .../parameters/gov/dfc/LHA/index.yaml | 3 + .../parameters/gov/dfc/LHA/percentile.yaml | 10 +++ policyengine_uk/parameters/gov/dfc/index.yaml | 3 + .../parameters/gov/dwp/LHA/freeze.yaml | 16 +---- .../parameters/gov/dwp/LHA/percentile.yaml | 2 +- policyengine_uk/tests/test_lha_freeze.py | 63 +++++++++++++++++++ .../tests/test_lha_published_rates.py | 14 ++++- policyengine_uk/utils/lha.py | 55 +++++++++++++--- 10 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 changelog.d/2502.fixed.md create mode 100644 policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml create mode 100644 policyengine_uk/parameters/gov/dfc/LHA/index.yaml create mode 100644 policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml create mode 100644 policyengine_uk/parameters/gov/dfc/index.yaml diff --git a/changelog.d/2502.fixed.md b/changelog.d/2502.fixed.md new file mode 100644 index 0000000000..90854a8756 --- /dev/null +++ b/changelog.d/2502.fixed.md @@ -0,0 +1 @@ +- Northern Ireland Local Housing Allowance rates now follow new gov.dfc.LHA.freeze and gov.dfc.LHA.percentile parameters, so reforms to gov.dwp.LHA.freeze and gov.dwp.LHA.percentile change Great Britain rates only. diff --git a/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml b/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml new file mode 100644 index 0000000000..6fcb91d966 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml @@ -0,0 +1,37 @@ +description: >- + While this parameter is true, Northern Ireland LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. It works as gov.dwp.LHA.freeze does for Great Britain, but follows the Northern Ireland Housing Executive's determinations under Northern Ireland regulations. Northern Ireland has so far made a matching regulation for each Great Britain Modification Order, so the values match the Great Britain parameter. A year here is the fiscal year from April. Values from 2027 onward reflect announced rather than enacted policy. +values: + 2015-01-01: false + 2020-04-06: + value: false + metadata: + reference: + - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 + href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made + 2021-04-06: + value: true + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2021 + href: https://www.legislation.gov.uk/nisr/2021/14/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment and Modification) Regulations (Northern Ireland) 2022 + href: https://www.legislation.gov.uk/nisr/2022/15/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2023 + href: https://www.legislation.gov.uk/nisr/2023/4/made + 2024-04-06: + value: false + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024 + href: https://www.legislation.gov.uk/nisr/2024/3/made + 2025-04-06: + value: true + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2025 + href: https://www.legislation.gov.uk/nisr/2025/1/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2026 + href: https://www.legislation.gov.uk/nisr/2026/2/made +metadata: + unit: bool + label: Northern Ireland LHA freeze diff --git a/policyengine_uk/parameters/gov/dfc/LHA/index.yaml b/policyengine_uk/parameters/gov/dfc/LHA/index.yaml new file mode 100644 index 0000000000..9afca181b9 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/index.yaml @@ -0,0 +1,3 @@ +description: Local Housing Allowance rules for Northern Ireland, where the Northern Ireland Housing Executive determines the rates under regulations made by the Department for Communities. The Rent Officers Orders behind gov.dwp.LHA cover Great Britain only. +metadata: + label: Northern Ireland LHA diff --git a/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml b/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml new file mode 100644 index 0000000000..1ffd6c79d7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml @@ -0,0 +1,10 @@ +description: Northern Ireland Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. It works as gov.dwp.LHA.percentile does for Great Britain. Northern Ireland's lists of rents in the model are copies of English ones, so another value scales the published 30th percentile by the median English ratio for the category. This parameter does not apply if Northern Ireland LHA is frozen, or to determinations before April 2020, which are taken as published. +values: + 2015-01-01: + value: 0.3 + reference: + - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 (April 2020 reset to the 30th percentile) + href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made +metadata: + unit: /1 + label: Northern Ireland LHA percentile diff --git a/policyengine_uk/parameters/gov/dfc/index.yaml b/policyengine_uk/parameters/gov/dfc/index.yaml new file mode 100644 index 0000000000..a6b03ecf30 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/index.yaml @@ -0,0 +1,3 @@ +metadata: + label: DfC + description: Department for Communities (Northern Ireland) diff --git a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml index 774aeeb681..5f847060a6 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml @@ -1,5 +1,5 @@ description: >- - While this parameter is true, LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. When it is false, rates are determined from that year's percentile rents (the 30th percentile by default), the national maximum, the anomalous-rate rule and, from April 2024, the March 2020 minimum. A year here is the fiscal year from April, so the value from 6 April 2025 governs the rates in force from April 2025. Determinations before April 2020 are taken as published, since they followed CPI and 1% uprating and then the 2016 to 2020 freeze with targeted affordability uplifts, so this parameter governs determinations from April 2020 onward. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. + While this parameter is true, Great Britain LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. When it is false, rates are determined from that year's percentile rents (the 30th percentile by default), the national maximum, the anomalous-rate rule and, from April 2024, the March 2020 minimum. A year here is the fiscal year from April, so the value from 6 April 2025 governs the rates in force from April 2025. Determinations before April 2020 are taken as published, since they followed CPI and 1% uprating and then the 2016 to 2020 freeze with targeted affordability uplifts, so this parameter governs determinations from April 2020 onward. The Rent Officers Orders apply in Great Britain only; Northern Ireland rates follow gov.dfc.LHA.freeze. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. values: 2015-01-01: false 2020-04-06: @@ -8,8 +8,6 @@ values: reference: - title: The Social Security (Coronavirus) (Further Measures) Regulations 2020, reg. 4 (April 2020 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2020/371/regulation/4/made - - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 - href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made 2021-04-06: value: true metadata: @@ -20,20 +18,12 @@ values: href: https://www.legislation.gov.uk/uksi/2021/1380/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2023 (determinations in 2023) href: https://www.legislation.gov.uk/uksi/2023/6/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2021 - href: https://www.legislation.gov.uk/nisr/2021/14/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment and Modification) Regulations (Northern Ireland) 2022 - href: https://www.legislation.gov.uk/nisr/2022/15/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2023 - href: https://www.legislation.gov.uk/nisr/2023/4/made 2024-04-06: value: false metadata: reference: - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Amendment) Order 2024 (April 2024 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2024/11/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024 - href: https://www.legislation.gov.uk/nisr/2024/3/made 2025-04-06: value: true metadata: @@ -42,10 +32,6 @@ values: href: https://www.legislation.gov.uk/uksi/2025/5/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2026 href: https://www.legislation.gov.uk/uksi/2026/5/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2025 - href: https://www.legislation.gov.uk/nisr/2025/1/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2026 - href: https://www.legislation.gov.uk/nisr/2026/2/made - title: Autumn Statement 2023 policy costings (April 2024 uplift maintained in cash terms thereafter) href: https://assets.publishing.service.gov.uk/media/655d0a83544aea000dfb321d/Autumn_Statement_2023_Policy_Costings_-_Final.pdf#page=16 metadata: diff --git a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml index 9cf92c8e56..fdbc9884be 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml @@ -1,4 +1,4 @@ -description: Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. The published tables give the 30th percentile; another value scales it by the ratio of the two percentiles in the model's list of rents for the same area and category (in Wales, Scotland and Northern Ireland, whose lists in the file are copies of English ones, the median English ratio for the category). This parameter does not apply if LHA is frozen, or to determinations before April 2020, which are taken as published. +description: Great Britain Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. The published tables give the 30th percentile; another value scales it by the ratio of the two percentiles in the model's list of rents for the same area and category (in Wales, whose lists in the file are copies of English ones, the median English ratio for the category). Northern Ireland rates follow gov.dfc.LHA.percentile. This parameter does not apply if LHA is frozen, or to determinations before April 2020, which are taken as published. values: 2015-01-01: value: 0.3 diff --git a/policyengine_uk/tests/test_lha_freeze.py b/policyengine_uk/tests/test_lha_freeze.py index d124cb3704..e7833c39e7 100644 --- a/policyengine_uk/tests/test_lha_freeze.py +++ b/policyengine_uk/tests/test_lha_freeze.py @@ -145,3 +145,66 @@ def test_frozen_years_hold_the_reset_level_for_an_uncapped_area(): assert _weekly_rate(frozen_year, "MAIDSTONE", "C") == pytest.approx( reset, abs=0.01 ) + + +# Northern Ireland: the Rent Officers Orders behind gov.dwp.LHA cover Great +# Britain only. The Northern Ireland Housing Executive determines Northern +# Ireland's rates under Northern Ireland regulations (gov.dfc.LHA). + +NI_LHA_VARIABLES = ("housing_benefit_LHA_rate", "LHA_cap", "uc_LHA_cap") + + +def _renter(year: int, region: str) -> dict: + return { + "people": {"person": {"age": {year: 30}}}, + "benunits": { + "benunit": { + "members": ["person"], + "LHA_category": {year: "C"}, + "housing_benefit_LHA_category": {year: "C"}, + "benunit_rent": {year: 100_000}, + } + }, + "households": { + "household": { + "members": ["person"], + "region": {year: region}, + "tenure_type": {year: "RENT_PRIVATELY"}, + "rent": {year: 100_000}, + } + }, + } + + +def _lha_values(region: str, reform=None, year: int = 2026) -> dict: + simulation = Simulation(situation=_renter(year, region), reform=reform) + return { + variable: float(simulation.calculate(variable, year)[0]) + for variable in NI_LHA_VARIABLES + } + + +def test_great_britain_lha_reforms_leave_northern_ireland_rates_unchanged(): + for reform in ( + {"gov.dwp.LHA.freeze": {"2026": False}}, + {"gov.dwp.LHA.percentile": {"2026": 0.5}}, + ): + assert _lha_values("NORTHERN_IRELAND", reform) == _lha_values( + "NORTHERN_IRELAND" + ) + + +def test_great_britain_lha_unfreeze_changes_great_britain_rates(): + baseline = _lha_values("LONDON") + unfrozen = _lha_values("LONDON", {"gov.dwp.LHA.freeze": {"2026": False}}) + for variable in NI_LHA_VARIABLES: + assert unfrozen[variable] != baseline[variable], variable + + +def test_northern_ireland_lha_unfreeze_changes_only_northern_ireland_rates(): + reform = {"gov.dfc.LHA.freeze": {"2026": False}} + baseline = _lha_values("NORTHERN_IRELAND") + unfrozen = _lha_values("NORTHERN_IRELAND", reform) + for variable in NI_LHA_VARIABLES: + assert unfrozen[variable] != baseline[variable], variable + assert _lha_values("LONDON", reform) == _lha_values("LONDON") diff --git a/policyengine_uk/tests/test_lha_published_rates.py b/policyengine_uk/tests/test_lha_published_rates.py index c046f7e476..2de404f609 100644 --- a/policyengine_uk/tests/test_lha_published_rates.py +++ b/policyengine_uk/tests/test_lha_published_rates.py @@ -240,12 +240,19 @@ def test_statutory_percentile_positions(): def reformed_parameters( freeze: dict, percentile: float, maximum_scale: float, minimum: bool ): - """Copies of the LHA parameters with reforms applied by year.""" + """Copies of the LHA parameters with reforms applied by year. + + The freeze and percentile reforms apply in Great Britain and, through + gov.dfc.LHA, in Northern Ireland alike. + """ lha = SYSTEM.parameters.gov.dwp.LHA.clone() + ni_lha = SYSTEM.parameters.gov.dfc.LHA.clone() for year, frozen in freeze.items(): - lha.freeze.update(period=str(year), value=frozen) + for node in (lha, ni_lha): + node.freeze.update(period=str(year), value=frozen) for year in range(2020, 2041): - lha.percentile.update(period=str(year), value=percentile) + for node in (lha, ni_lha): + node.percentile.update(period=str(year), value=percentile) lha.march_2020_minimum.update(period=str(year), value=minimum) for node in (lha.maximum, lha.maximum_monthly): for category in CATEGORIES: @@ -257,6 +264,7 @@ def reformed_parameters( return SimpleNamespace( gov=SimpleNamespace( dwp=SimpleNamespace(LHA=lha), + dfc=SimpleNamespace(LHA=ni_lha), indices=SYSTEM.parameters.gov.indices, ) ) diff --git a/policyengine_uk/utils/lha.py b/policyengine_uk/utils/lha.py index e8ff422c60..c2714ce03f 100644 --- a/policyengine_uk/utils/lha.py +++ b/policyengine_uk/utils/lha.py @@ -313,14 +313,19 @@ def determination( determined: int, universal_credit: bool = False, adjustment: np.ndarray | None = None, + percentile_parameter=None, ): """The rates determined in ``determined`` for every BRMA and category. ``adjustment`` is added to the percentile rents before the rules apply - (see ``restatement``). Returns (percentile, rate) as [BRMA, category] - arrays, weekly for Housing Benefit and monthly for Universal Credit. + (see ``restatement``). ``percentile_parameter`` is the percentile to + determine at, ``gov.dwp.LHA.percentile`` (Great Britain) by default. + Returns (percentile, rate) as [BRMA, category] arrays, weekly for Housing + Benefit and monthly for Universal Credit. """ lha = parameters.gov.dwp.LHA + if percentile_parameter is None: + percentile_parameter = lha.percentile rates = published_rates() if determined < FIRST_RULES_YEAR: @@ -361,7 +366,7 @@ def determination( growth[base_year == value] = index(str(determined)) / index(str(value)) percentile = percentile * growth - share = lha.percentile(str(determined)) + share = percentile_parameter(str(determined)) if abs(share - PUBLISHED_PERCENTILE) > 1e-9: percentile = percentile * _percentile_ratios(float(share)) if adjustment is not None: @@ -399,16 +404,52 @@ def lha_rates(parameters, year: int, universal_credit: bool = False) -> dict: minimum rules) and ``rate`` (the LHA in force), both [BRMA, category] arrays, weekly for Housing Benefit and monthly for Universal Credit. """ - lha = parameters.gov.dwp.LHA + great_britain = parameters.gov.dwp.LHA + northern_ireland = parameters.gov.dfc.LHA + percentile, rate = _rates_in_force( + parameters, year, universal_credit, great_britain + ) + # The Rent Officers Orders behind gov.dwp.LHA cover Great Britain; the + # Northern Ireland Housing Executive determines Northern Ireland's rates + # under Northern Ireland regulations, which gov.dfc.LHA follows. Where + # the two would give the same determination, the Great Britain table + # already holds Northern Ireland's rates. + gb_year = determination_year(great_britain, year) + ni_year = determination_year(northern_ireland, year) + if ni_year != gb_year or great_britain.percentile( + str(gb_year) + ) != northern_ireland.percentile(str(ni_year)): + ni_percentile, ni_rate = _rates_in_force( + parameters, year, universal_credit, northern_ireland + ) + in_ni = northern_ireland_brmas()[:, None] + percentile = np.where(in_ni, ni_percentile, percentile) + rate = np.where(in_ni, ni_rate, rate) + return dict(brmas=published_rates().brmas, percentile=percentile, rate=rate) + + +def _rates_in_force(parameters, year: int, universal_credit: bool, lha) -> tuple: + """(percentile, rate) in force in ``year`` under one nation's parameters. + + ``lha`` is the parameter node holding ``freeze`` and ``percentile``: + ``gov.dwp.LHA`` for Great Britain or ``gov.dfc.LHA`` for Northern Ireland. + """ determined = determination_year(lha, year) adjustment = None if determined != year and determined >= FIRST_RULES_YEAR: measure = "uc_rate" if universal_credit else "rate" adjustment = restatement(measure, determined, year) - percentile, rate = determination( - parameters, determined, universal_credit, adjustment + return determination( + parameters, determined, universal_credit, adjustment, lha.percentile ) - return dict(brmas=published_rates().brmas, percentile=percentile, rate=rate) + + +@lru_cache(maxsize=1) +def northern_ireland_brmas() -> np.ndarray: + """Whether each BRMA in the published tables is in Northern Ireland.""" + households = pd.read_csv(LHA_DIRECTORY / "brma_private_rented_households.csv.gz") + names = households.brma[households.region == "NORTHERN_IRELAND"].unique() + return published_rates().brmas.isin(names) def benunit_lha( From 7a5b55915649809c79c35e50356fbbcb23c5574b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:23:27 +0100 Subject: [PATCH 061/141] Require runtime identity for aggregate-impact data comparisons --- changelog.d/2499.fixed.md | 1 + .../test_aggregate_impact_compare.py | 54 +++++++++++++++++++ .../utils/aggregate_impact/compare.py | 2 + 3 files changed, 57 insertions(+) create mode 100644 changelog.d/2499.fixed.md diff --git a/changelog.d/2499.fixed.md b/changelog.d/2499.fixed.md new file mode 100644 index 0000000000..5cac9e7146 --- /dev/null +++ b/changelog.d/2499.fixed.md @@ -0,0 +1 @@ +- Reject data-axis aggregate-impact comparisons when either measurement omits its Python version or machine identity. diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py index b605219a9b..2ec0aa18e6 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py @@ -1446,3 +1446,57 @@ def test_withheld_decile_years_are_explained_not_called_missing(): assert note in text and "need household detail" not in text text = _deciles({"deciles": {}, "decile_notes": []}, [2026]) assert "need household detail" in text + + +@pytest.mark.parametrize( + "fields", + [("python",), ("machine",), ("python", "machine")], + ids=["python", "machine", "both-fields"], +) +@pytest.mark.parametrize( + "sides", + [("base",), ("head",), ("base", "head")], + ids=["base", "head", "both-sides"], +) +def test_issue_2499_data_axis_requires_runtime_identity_on_each_side(fields, sides): + measurements = {"base": _on("1" * 64), "head": _on("2" * 64)} + complete = compare(measurements["base"], measurements["head"], SPEC, axis="data") + assert complete["status"] == "pass", complete["failures"] + for side in sides: + for field in fields: + del measurements[side]["runtime"][field] + + summary = compare(measurements["base"], measurements["head"], SPEC, axis="data") + + assert summary["status"] == "fail" + for field in fields: + assert any(field in failure.lower() for failure in summary["failures"]) + + +@pytest.mark.parametrize("field", ["python", "machine"]) +@pytest.mark.parametrize("value", [None, ""], ids=["null", "empty"]) +def test_issue_2499_data_axis_rejects_empty_runtime_identity(field, value): + base, head = _on("1" * 64), _on("2" * 64) + base["runtime"][field] = head["runtime"][field] = value + + summary = compare(base, head, SPEC, axis="data") + + assert summary["status"] == "fail" + assert any(field in failure.lower() for failure in summary["failures"]) + + +def test_issue_2499_complete_runtime_identity_allows_different_datasets(): + summary = compare(_on("1" * 64), _on("2" * 64), SPEC, axis="data") + + assert summary["status"] == "pass", summary["failures"] + + +def test_issue_2499_model_axis_preserves_missing_runtime_identity_behavior(): + base, head = _on("1" * 64), _on("1" * 64) + for result in (base, head): + del result["runtime"]["python"] + del result["runtime"]["machine"] + + summary = compare(base, head, SPEC, axis="model") + + assert summary["status"] == "pass", summary["failures"] diff --git a/policyengine_uk/utils/aggregate_impact/compare.py b/policyengine_uk/utils/aggregate_impact/compare.py index e5dbb9c009..dc1a161bcf 100644 --- a/policyengine_uk/utils/aggregate_impact/compare.py +++ b/policyengine_uk/utils/aggregate_impact/compare.py @@ -733,6 +733,8 @@ def field(result, *path): "policyengine-uk version": ("runtime", "policyengine_uk"), "policyengine-uk fingerprint": ("runtime", "policyengine_uk_fingerprint"), "runtime listing": ("runtime", "distributions"), + "Python version": ("runtime", "python"), + "machine": ("runtime", "machine"), "fiscal partition": ("partition", "sha256"), "metric definitions": ("provenance", "metrics_version"), } From 4044d6ccfe2fabeda7413529b120254cc517d49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:01:24 +0100 Subject: [PATCH 062/141] Fix recent residential SDLT purchase schedules and inclusive first-buyer caps --- changelog.d/2245.fixed.md | 1 + docs/book/programs/gov/hmrc/stamp-duty.ipynb | 6417 +---------------- .../residential/purchase/additional/rate.yaml | 14 + .../residential/purchase/main/first/max.yaml | 4 + .../residential/purchase/main/first/rate.yaml | 4 + .../residential/purchase/main/subsequent.yaml | 6 + .../hmrc/stamp_duty/stamp_duty_land_tax.yaml | 6 +- .../stamp_duty_land_tax_history.yaml | 150 + ...rty_transaction_tax_purchase_properties.py | 2 +- .../tests/test_sdlt_purchase_schedules.py | 158 + ...lt_on_residential_property_transactions.py | 2 +- 11 files changed, 464 insertions(+), 6300 deletions(-) create mode 100644 changelog.d/2245.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml create mode 100644 policyengine_uk/tests/test_sdlt_purchase_schedules.py diff --git a/changelog.d/2245.fixed.md b/changelog.d/2245.fixed.md new file mode 100644 index 0000000000..18d03b4389 --- /dev/null +++ b/changelog.d/2245.fixed.md @@ -0,0 +1 @@ +Updated recent residential SDLT purchase schedules and applied the inclusive first-time-buyer price maximum, retaining annual sampling and contract-protection limitations. diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index b7975450bf..c868176975 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -21,6314 +21,141 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Rates\n", - "\n", - "The rates of SDLT depend on various factors, including the purchase price of the property, property type, and whether it is an additional property or a purchase by a first-time buyer. Generally, SDLT rates are higher for properties or land with a purchase price exceeding a specific threshold, often referred to as \"stamp duty bands\" or \"stamp duty brackets.\" The rules and thresholds for SDLT may change over time due to government policy adjustments. First-time buyers may be eligible for reduced SDLT rates if the purchase price of the property is not more than £500,000. \n", - "\n", - "Here we show current rates and thresholds for SDLT under different situations in 2023 below:\n", - "\n", - "
  • Residential
  • \n", - "\n", - "- Rent\n", - "\n", - " Up to £125,000: 0%\n", - " \n", - " Over £125,000: 1%\n", - "\n", - "- Purchase\n", - " - Main property \n", - " \n", - " - First-time\n", - "\n", - " Up to £300,000: 0%\n", - "\n", - " £300,000 to £500,000: 5%\n", - "\n", - " Over £500,000: Ineligible for relief, follow standard rates\n", - "\n", - " - Subsequent (standard rates):\n", - "\n", - " Up to £125,000: 0%\n", - "\n", - " £125,000 to £250,000: 2%\n", - "\n", - " £250,000 to £925,000: 5%\n", - "\n", - " £925,000 to £1,500,000: 10%\n", - "\n", - " Over £1,500,000: 12%\n", - "\n", - " - Additional property \n", - "\n", - " An additional 3% on top of the standard rates for each band. However, a secondary property or land with a purchase price or market value less than £40,000 will be exempt from SDLT.\n", - "\n", - " Less than £40,000: 0%\n", - " \n", - " £40,000 to £125,000: 3%\n", - "\n", - " £125,000 to £250,000: 5%\n", - "\n", - " £250,000 to £925,000: 8%\n", - "\n", - " £925,000 to £1,500,000: 13%\n", - "\n", - " Over £1,500,000: 15%\n", - "\n", - "
  • Non-residential
  • \n", - "\n", - "- Rent\n", - "\n", - " Up to £150,000: 0%\n", - "\n", - " £150,000 to £5,000,000: 1%\n", - " \n", - " Over £5,000,000: 2%\n", - "\n", - "- Purchase\n", - "\n", - " Up to £150,000: 0%\n", - "\n", - " £150,000 to £250,000: 2%\n", - " \n", - " Over £250,000: 5%" - ] - }, - { - "cell_type": "code", - "execution_count": 77, - "metadata": { - "tags": [ - "hide-input" - ] - }, - "outputs": [ - { - "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "hovertemplate": "Label=Residential rent
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential rent", - "line": { - "color": "#636efa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Residential rent", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 125000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.01, - 0.01 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Label=Residential purchase main (first home)
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase main (first home)", - "line": { - "color": "#EF553B", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Residential purchase main (first home)", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 300000, - 500000, - 925000, - 1500000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.05, - 0.05, - 0.1, - 0.12, - 0.12 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Label=Residential purchase main standard
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase main standard", - "line": { - "color": "#00cc96", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Residential purchase main standard", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 125000, - 250000, - 925000, - 1500000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.02, - 0.05, - 0.1, - 0.12, - 0.12 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Label=Residential purchase additional
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase additional", - "line": { - "color": "#ab63fa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Residential purchase additional", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 40000, - 125000, - 250000, - 925000, - 1500000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.03, - 0.05, - 0.08, - 0.13, - 0.15, - 0.15 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Label=Non-residential rent
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Non-residential rent", - "line": { - "color": "#FFA15A", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Non-residential rent", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 150000, - 5000000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.01, - 0.02, - 0.02 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Label=Non-residential purchase
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Non-residential purchase", - "line": { - "color": "#19d3f3", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "Non-residential purchase", - "orientation": "v", - "showlegend": true, - "type": "scatter", - "x": [ - 0, - 150000, - 250000, - 5200000 - ], - "xaxis": "x", - "y": [ - 0, - 0.02, - 0.05, - 0.05 - ], - "yaxis": "y" - } - ], - "layout": { - "font": { - "color": "black", - "family": "Roboto Serif" - }, - "height": 600, - "images": [ - { - "sizex": 0.2, - "sizey": 0.2, - "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", - "x": 1, - "xanchor": "right", - "xref": "paper", - "y": -0.15, - "yanchor": "bottom", - "yref": "paper" - } - ], - "legend": { - "orientation": "h", - "title": { - "text": "" - }, - "tracegroupgap": 0, - "x": 0.9, - "xanchor": "right", - "y": -0.3, - "yanchor": "bottom" - }, - "modebar": { - "bgcolor": "rgba(0,0,0,0)", - "color": "rgba(0,0,0,0)" - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "heatmapgl": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmapgl" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "white", - "showlakes": true, - "showland": true, - "subunitcolor": "#C8D4E3" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "white", - "polar": { - "angularaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - }, - "bgcolor": "white", - "radialaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "yaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "zaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "baxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "bgcolor": "white", - "caxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - } - } - }, - "title": { - "text": "Stamp duty land tax rates over property price thresholds" - }, - "width": 800, - "xaxis": { - "anchor": "y", - "domain": [ - 0, - 1 - ], - "tickprefix": "£", - "title": { - "text": "Threshold" - } - }, - "yaxis": { - "anchor": "x", - "domain": [ - 0, - 1 - ], - "tickformat": ",.0%", - "title": { - "text": "Rate" - } - } - } - } - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import pandas as pd\n", - "import plotly.express as px\n", - "from policyengine_core.charts import format_fig\n", - "\n", - "residential_rent = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 125000, 5200000],\n", - " \"Rate\": [0, 0.01, 0.01],\n", - " \"Label\": [\"Residential rent\"] * 3,\n", - " }\n", - ")\n", - "residential_purchase_main_first = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 300000, 500000, 925000, 1500000, 5200000],\n", - " \"Rate\": [0, 0.05, 0.05, 0.1, 0.12, 0.12],\n", - " \"Label\": [\"Residential purchase main (first home)\"] * 6,\n", - " }\n", - ")\n", - "residential_purchase_main_standard = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 125000, 250000, 925000, 1500000, 5200000],\n", - " \"Rate\": [0, 0.02, 0.05, 0.1, 0.12, 0.12],\n", - " \"Label\": [\"Residential purchase main standard\"] * 6,\n", - " }\n", - ")\n", - "residential_purchase_additional = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 40000, 125000, 250000, 925000, 1500000, 5200000],\n", - " \"Rate\": [0, 0.03, 0.05, 0.08, 0.13, 0.15, 0.15],\n", - " \"Label\": [\"Residential purchase additional\"] * 7,\n", - " }\n", - ")\n", - "non_residential_rent = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 150000, 5000000, 5200000],\n", - " \"Rate\": [0, 0.01, 0.02, 0.02],\n", - " \"Label\": [\"Non-residential rent\"] * 4,\n", - " }\n", - ")\n", - "non_residential_purchase = pd.DataFrame(\n", - " {\n", - " \"Threshold\": [0, 150000, 250000, 5200000],\n", - " \"Rate\": [0, 0.02, 0.05, 0.05],\n", - " \"Label\": [\"Non-residential purchase\"] * 4,\n", - " }\n", - ")\n", - "df_list = [\n", - " residential_rent,\n", - " residential_purchase_main_first,\n", - " residential_purchase_main_standard,\n", - " residential_purchase_additional,\n", - " non_residential_rent,\n", - " non_residential_purchase,\n", - "]\n", - "df = pd.concat(df_list)\n", - "# plot\n", - "fig = (\n", - " px.line(\n", - " df,\n", - " x=\"Threshold\",\n", - " y=\"Rate\",\n", - " color=\"Label\",\n", - " title=\"Stamp duty land tax rates over property price thresholds\",\n", - " )\n", - " .update_layout(\n", - " yaxis_tickformat=\",.0%\",\n", - " xaxis_tickprefix=\"£\",\n", - " legend=dict(\n", - " orientation=\"h\",\n", - " yanchor=\"bottom\",\n", - " y=-0.3,\n", - " xanchor=\"right\",\n", - " x=0.9,\n", - " title=\"\",\n", - " ),\n", - " )\n", - " .update_traces(line_shape=\"hv\")\n", - ")\n", - "fig = format_fig(fig)\n", - "fig" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The first home discount only applies for homes priced under £500,000. When simulating the marginal effect of SDLT, we find a cliff effect for first-time buyers." - ] - }, - { - "cell_type": "code", - "execution_count": 88, - "metadata": { - "tags": [ - "hide-input" - ] - }, - "outputs": [ - { - "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "hovertemplate": "Home price=%{x}
    Marginal SDLT rate=%{y}", - "legendgroup": "", - "line": { - "color": "#636efa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "", - "orientation": "v", - "showlegend": false, - "type": "scatter", - "x": [ - 0, - 5005.0048828125, - 10010.009765625, - 15015.0146484375, - 20020.01953125, - 25025.025390625, - 30030.029296875, - 35035.03515625, - 40040.0390625, - 45045.046875, - 50050.05078125, - 55055.0546875, - 60060.05859375, - 65065.06640625, - 70070.0703125, - 75075.078125, - 80080.078125, - 85085.0859375, - 90090.09375, - 95095.09375, - 100100.1015625, - 105105.1015625, - 110110.109375, - 115115.1171875, - 120120.1171875, - 125125.125, - 130130.1328125, - 135135.140625, - 140140.140625, - 145145.140625, - 150150.15625, - 155155.15625, - 160160.15625, - 165165.171875, - 170170.171875, - 175175.171875, - 180180.1875, - 185185.1875, - 190190.1875, - 195195.1875, - 200200.203125, - 205205.203125, - 210210.203125, - 215215.21875, - 220220.21875, - 225225.21875, - 230230.234375, - 235235.234375, - 240240.234375, - 245245.25, - 250250.25, - 255255.25, - 260260.265625, - 265265.25, - 270270.28125, - 275275.28125, - 280280.28125, - 285285.28125, - 290290.28125, - 295295.28125, - 300300.3125, - 305305.3125, - 310310.3125, - 315315.3125, - 320320.3125, - 325325.3125, - 330330.34375, - 335335.34375, - 340340.34375, - 345345.34375, - 350350.34375, - 355355.34375, - 360360.375, - 365365.375, - 370370.375, - 375375.375, - 380380.375, - 385385.375, - 390390.375, - 395395.40625, - 400400.40625, - 405405.40625, - 410410.40625, - 415415.40625, - 420420.40625, - 425425.4375, - 430430.4375, - 435435.4375, - 440440.4375, - 445445.4375, - 450450.4375, - 455455.46875, - 460460.46875, - 465465.46875, - 470470.46875, - 475475.46875, - 480480.46875, - 485485.5, - 490490.5, - 495495.5, - 500500.5, - 505505.5, - 510510.5, - 515515.5, - 520520.53125, - 525525.5, - 530530.5, - 535535.5625, - 540540.5625, - 545545.5625, - 550550.5625, - 555555.5625, - 560560.5625, - 565565.5625, - 570570.5625, - 575575.5625, - 580580.5625, - 585585.5625, - 590590.5625, - 595595.625, - 600600.625, - 605605.625, - 610610.625, - 615615.625, - 620620.625, - 625625.625, - 630630.625, - 635635.625, - 640640.625, - 645645.625, - 650650.625, - 655655.625, - 660660.6875, - 665665.6875, - 670670.6875, - 675675.6875, - 680680.6875, - 685685.6875, - 690690.6875, - 695695.6875, - 700700.6875, - 705705.6875, - 710710.6875, - 715715.6875, - 720720.75, - 725725.75, - 730730.75, - 735735.75, - 740740.75, - 745745.75, - 750750.75, - 755755.75, - 760760.75, - 765765.75, - 770770.75, - 775775.75, - 780780.75, - 785785.8125, - 790790.8125, - 795795.8125, - 800800.8125, - 805805.8125, - 810810.8125, - 815815.8125, - 820820.8125, - 825825.8125, - 830830.8125, - 835835.8125, - 840840.8125, - 845845.875, - 850850.875, - 855855.875, - 860860.875, - 865865.875, - 870870.875, - 875875.875, - 880880.875, - 885885.875, - 890890.875, - 895895.875, - 900900.875, - 905905.875, - 910910.9375, - 915915.9375, - 920920.9375, - 925925.9375, - 930930.9375, - 935935.9375, - 940940.9375, - 945945.9375, - 950950.9375, - 955955.9375, - 960960.9375, - 965965.9375, - 970971, - 975976, - 980981, - 985986, - 990991, - 995996, - 1001001, - 1006006, - 1011011, - 1016016, - 1021021, - 1026026, - 1031031, - 1036036.0625, - 1041041.0625, - 1046046.0625, - 1051051, - 1056056, - 1061061, - 1066066.125, - 1071071.125, - 1076076.125, - 1081081.125, - 1086086.125, - 1091091.125, - 1096096.125, - 1101101.125, - 1106106.125, - 1111111.125, - 1116116.125, - 1121121.125, - 1126126.125, - 1131131.125, - 1136136.125, - 1141141.125, - 1146146.125, - 1151151.125, - 1156156.125, - 1161161.125, - 1166166.125, - 1171171.125, - 1176176.125, - 1181181.125, - 1186186.125, - 1191191.25, - 1196196.25, - 1201201.25, - 1206206.25, - 1211211.25, - 1216216.25, - 1221221.25, - 1226226.25, - 1231231.25, - 1236236.25, - 1241241.25, - 1246246.25, - 1251251.25, - 1256256.25, - 1261261.25, - 1266266.25, - 1271271.25, - 1276276.25, - 1281281.25, - 1286286.25, - 1291291.25, - 1296296.25, - 1301301.25, - 1306306.25, - 1311311.25, - 1316316.375, - 1321321.375, - 1326326.375, - 1331331.375, - 1336336.375, - 1341341.375, - 1346346.375, - 1351351.375, - 1356356.375, - 1361361.375, - 1366366.375, - 1371371.375, - 1376376.375, - 1381381.375, - 1386386.375, - 1391391.375, - 1396396.375, - 1401401.375, - 1406406.375, - 1411411.375, - 1416416.375, - 1421421.375, - 1426426.375, - 1431431.375, - 1436436.375, - 1441441.5, - 1446446.5, - 1451451.5, - 1456456.5, - 1461461.5, - 1466466.5, - 1471471.5, - 1476476.5, - 1481481.5, - 1486486.5, - 1491491.5, - 1496496.5, - 1501501.5, - 1506506.5, - 1511511.5, - 1516516.5, - 1521521.5, - 1526526.5, - 1531531.5, - 1536536.5, - 1541541.5, - 1546546.5, - 1551551.5, - 1556556.5, - 1561561.5, - 1566566.625, - 1571571.625, - 1576576.625, - 1581581.625, - 1586586.625, - 1591591.625, - 1596596.625, - 1601601.625, - 1606606.625, - 1611611.625, - 1616616.625, - 1621621.625, - 1626626.625, - 1631631.625, - 1636636.625, - 1641641.625, - 1646646.625, - 1651651.625, - 1656656.625, - 1661661.625, - 1666666.625, - 1671671.625, - 1676676.625, - 1681681.625, - 1686686.625, - 1691691.75, - 1696696.75, - 1701701.75, - 1706706.75, - 1711711.75, - 1716716.75, - 1721721.75, - 1726726.75, - 1731731.75, - 1736736.75, - 1741741.75, - 1746746.75, - 1751751.75, - 1756756.75, - 1761761.75, - 1766766.75, - 1771771.75, - 1776776.75, - 1781781.75, - 1786786.75, - 1791791.75, - 1796796.75, - 1801801.75, - 1806806.75, - 1811811.75, - 1816816.875, - 1821821.875, - 1826826.875, - 1831831.875, - 1836836.875, - 1841841.875, - 1846846.875, - 1851851.875, - 1856856.875, - 1861861.875, - 1866866.875, - 1871871.875, - 1876876.875, - 1881881.875, - 1886886.875, - 1891891.875, - 1896896.875, - 1901901.875, - 1906906.875, - 1911911.875, - 1916916.875, - 1921921.875, - 1926926.875, - 1931931.875, - 1936936.875, - 1941942, - 1946947, - 1951952, - 1956957, - 1961962, - 1966967, - 1971972, - 1976977, - 1981982, - 1986987, - 1991992, - 1996997, - 2002002, - 2007007, - 2012012, - 2017017, - 2022022, - 2027027, - 2032032, - 2037037, - 2042042, - 2047047, - 2052052, - 2057057, - 2062062, - 2067067.125, - 2072072.125, - 2077077.125, - 2082082.125, - 2087087.125, - 2092092.125, - 2097097.125, - 2102102, - 2107107, - 2112112, - 2117117, - 2122122, - 2127127.25, - 2132132.25, - 2137137.25, - 2142142.25, - 2147147.25, - 2152152.25, - 2157157.25, - 2162162.25, - 2167167.25, - 2172172.25, - 2177177.25, - 2182182.25, - 2187187.25, - 2192192.25, - 2197197.25, - 2202202.25, - 2207207.25, - 2212212.25, - 2217217.25, - 2222222.25, - 2227227.25, - 2232232.25, - 2237237.25, - 2242242.25, - 2247247.25, - 2252252.25, - 2257257.25, - 2262262.25, - 2267267.25, - 2272272.25, - 2277277.25, - 2282282.25, - 2287287.25, - 2292292.25, - 2297297.25, - 2302302.25, - 2307307.25, - 2312312.25, - 2317317.25, - 2322322.25, - 2327327.25, - 2332332.25, - 2337337.25, - 2342342.25, - 2347347.25, - 2352352.25, - 2357357.25, - 2362362.25, - 2367367.25, - 2372372.25, - 2377377.5, - 2382382.5, - 2387387.5, - 2392392.5, - 2397397.5, - 2402402.5, - 2407407.5, - 2412412.5, - 2417417.5, - 2422422.5, - 2427427.5, - 2432432.5, - 2437437.5, - 2442442.5, - 2447447.5, - 2452452.5, - 2457457.5, - 2462462.5, - 2467467.5, - 2472472.5, - 2477477.5, - 2482482.5, - 2487487.5, - 2492492.5, - 2497497.5, - 2502502.5, - 2507507.5, - 2512512.5, - 2517517.5, - 2522522.5, - 2527527.5, - 2532532.5, - 2537537.5, - 2542542.5, - 2547547.5, - 2552552.5, - 2557557.5, - 2562562.5, - 2567567.5, - 2572572.5, - 2577577.5, - 2582582.5, - 2587587.5, - 2592592.5, - 2597597.5, - 2602602.5, - 2607607.5, - 2612612.5, - 2617617.5, - 2622622.5, - 2627627.75, - 2632632.75, - 2637637.75, - 2642642.75, - 2647647.75, - 2652652.75, - 2657657.75, - 2662662.75, - 2667667.75, - 2672672.75, - 2677677.75, - 2682682.75, - 2687687.75, - 2692692.75, - 2697697.75, - 2702702.75, - 2707707.75, - 2712712.75, - 2717717.75, - 2722722.75, - 2727727.75, - 2732732.75, - 2737737.75, - 2742742.75, - 2747747.75, - 2752752.75, - 2757757.75, - 2762762.75, - 2767767.75, - 2772772.75, - 2777777.75, - 2782782.75, - 2787787.75, - 2792792.75, - 2797797.75, - 2802802.75, - 2807807.75, - 2812812.75, - 2817817.75, - 2822822.75, - 2827827.75, - 2832832.75, - 2837837.75, - 2842842.75, - 2847847.75, - 2852852.75, - 2857857.75, - 2862862.75, - 2867867.75, - 2872872.75, - 2877878, - 2882883, - 2887888, - 2892893, - 2897898, - 2902903, - 2907908, - 2912913, - 2917918, - 2922923, - 2927928, - 2932933, - 2937938, - 2942943, - 2947948, - 2952953, - 2957958, - 2962963, - 2967968, - 2972973, - 2977978, - 2982983, - 2987988, - 2992993, - 2997998, - 3003003, - 3008008, - 3013013, - 3018018, - 3023023, - 3028028, - 3033033, - 3038038, - 3043043, - 3048048, - 3053053, - 3058058, - 3063063, - 3068068, - 3073073, - 3078078, - 3083083, - 3088088, - 3093093, - 3098098, - 3103103, - 3108108, - 3113113, - 3118118, - 3123123, - 3128128.25, - 3133133.25, - 3138138.25, - 3143143.25, - 3148148.25, - 3153153.25, - 3158158.25, - 3163163.25, - 3168168.25, - 3173173.25, - 3178178.25, - 3183183.25, - 3188188.25, - 3193193.25, - 3198198.25, - 3203203.25, - 3208208.25, - 3213213.25, - 3218218.25, - 3223223.25, - 3228228.25, - 3233233.25, - 3238238.25, - 3243243.25, - 3248248.25, - 3253253.25, - 3258258.25, - 3263263.25, - 3268268.25, - 3273273.25, - 3278278.25, - 3283283.25, - 3288288.25, - 3293293.25, - 3298298.25, - 3303303.25, - 3308308.25, - 3313313.25, - 3318318.25, - 3323323.25, - 3328328.25, - 3333333.25, - 3338338.25, - 3343343.25, - 3348348.25, - 3353353.25, - 3358358.25, - 3363363.25, - 3368368.25, - 3373373.25, - 3378378.5, - 3383383.5, - 3388388.5, - 3393393.5, - 3398398.5, - 3403403.5, - 3408408.5, - 3413413.5, - 3418418.5, - 3423423.5, - 3428428.5, - 3433433.5, - 3438438.5, - 3443443.5, - 3448448.5, - 3453453.5, - 3458458.5, - 3463463.5, - 3468468.5, - 3473473.5, - 3478478.5, - 3483483.5, - 3488488.5, - 3493493.5, - 3498498.5, - 3503503.5, - 3508508.5, - 3513513.5, - 3518518.5, - 3523523.5, - 3528528.5, - 3533533.5, - 3538538.5, - 3543543.5, - 3548548.5, - 3553553.5, - 3558558.5, - 3563563.5, - 3568568.5, - 3573573.5, - 3578578.5, - 3583583.5, - 3588588.5, - 3593593.5, - 3598598.5, - 3603603.5, - 3608608.5, - 3613613.5, - 3618618.5, - 3623623.5, - 3628628.75, - 3633633.75, - 3638638.75, - 3643643.75, - 3648648.75, - 3653653.75, - 3658658.75, - 3663663.75, - 3668668.75, - 3673673.75, - 3678678.75, - 3683683.75, - 3688688.75, - 3693693.75, - 3698698.75, - 3703703.75, - 3708708.75, - 3713713.75, - 3718718.75, - 3723723.75, - 3728728.75, - 3733733.75, - 3738738.75, - 3743743.75, - 3748748.75, - 3753753.75, - 3758758.75, - 3763763.75, - 3768768.75, - 3773773.75, - 3778778.75, - 3783783.75, - 3788788.75, - 3793793.75, - 3798798.75, - 3803803.75, - 3808808.75, - 3813813.75, - 3818818.75, - 3823823.75, - 3828828.75, - 3833833.75, - 3838838.75, - 3843843.75, - 3848848.75, - 3853853.75, - 3858858.75, - 3863863.75, - 3868868.75, - 3873873.75, - 3878879, - 3883884, - 3888889, - 3893894, - 3898899, - 3903904, - 3908909, - 3913914, - 3918919, - 3923924, - 3928929, - 3933934, - 3938939, - 3943944, - 3948949, - 3953954, - 3958959, - 3963964, - 3968969, - 3973974, - 3978979, - 3983984, - 3988989, - 3993994, - 3998999, - 4004004, - 4009009, - 4014014, - 4019019, - 4024024, - 4029029, - 4034034, - 4039039, - 4044044, - 4049049, - 4054054, - 4059059, - 4064064, - 4069069, - 4074074, - 4079079, - 4084084, - 4089089, - 4094094, - 4099099, - 4104104, - 4109109, - 4114114, - 4119119, - 4124124, - 4129129.25, - 4134134.25, - 4139139.25, - 4144144.25, - 4149149.25, - 4154154.25, - 4159159.25, - 4164164.25, - 4169169.25, - 4174174.25, - 4179179.25, - 4184184.25, - 4189189.25, - 4194194.25, - 4199199, - 4204204, - 4209209, - 4214214, - 4219219, - 4224224, - 4229229, - 4234234, - 4239239, - 4244244, - 4249249, - 4254254.5, - 4259259.5, - 4264264.5, - 4269269.5, - 4274274.5, - 4279279.5, - 4284284.5, - 4289289.5, - 4294294.5, - 4299299.5, - 4304304.5, - 4309309.5, - 4314314.5, - 4319319.5, - 4324324.5, - 4329329.5, - 4334334.5, - 4339339.5, - 4344344.5, - 4349349.5, - 4354354.5, - 4359359.5, - 4364364.5, - 4369369.5, - 4374374.5, - 4379379.5, - 4384384.5, - 4389389.5, - 4394394.5, - 4399399.5, - 4404404.5, - 4409409.5, - 4414414.5, - 4419419.5, - 4424424.5, - 4429429.5, - 4434434.5, - 4439439.5, - 4444444.5, - 4449449.5, - 4454454.5, - 4459459.5, - 4464464.5, - 4469469.5, - 4474474.5, - 4479479.5, - 4484484.5, - 4489489.5, - 4494494.5, - 4499499.5, - 4504504.5, - 4509509.5, - 4514514.5, - 4519519.5, - 4524524.5, - 4529529.5, - 4534534.5, - 4539539.5, - 4544544.5, - 4549549.5, - 4554554.5, - 4559559.5, - 4564564.5, - 4569569.5, - 4574574.5, - 4579579.5, - 4584584.5, - 4589589.5, - 4594594.5, - 4599599.5, - 4604604.5, - 4609609.5, - 4614614.5, - 4619619.5, - 4624624.5, - 4629629.5, - 4634634.5, - 4639639.5, - 4644644.5, - 4649649.5, - 4654654.5, - 4659659.5, - 4664664.5, - 4669669.5, - 4674674.5, - 4679679.5, - 4684684.5, - 4689689.5, - 4694694.5, - 4699699.5, - 4704704.5, - 4709709.5, - 4714714.5, - 4719719.5, - 4724724.5, - 4729729.5, - 4734734.5, - 4739739.5, - 4744744.5, - 4749749.5, - 4754755, - 4759760, - 4764765, - 4769770, - 4774775, - 4779780, - 4784785, - 4789790, - 4794795, - 4799800, - 4804805, - 4809810, - 4814815, - 4819820, - 4824825, - 4829830, - 4834835, - 4839840, - 4844845, - 4849850, - 4854855, - 4859860, - 4864865, - 4869870, - 4874875, - 4879880, - 4884885, - 4889890, - 4894895, - 4899900, - 4904905, - 4909910, - 4914915, - 4919920, - 4924925, - 4929930, - 4934935, - 4939940, - 4944945, - 4949950, - 4954955, - 4959960, - 4964965, - 4969970, - 4974975, - 4979980, - 4984985, - 4989990, - 4994995 - ], - "xaxis": "x", - "y": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0.0030001061968505383, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000447034836, - 0.05000000074505806, - 0.05000000074505806, - 0.04999997466802597, - 0.05000000074505806, - 0.05000000074505806, - 0.05000003054738045, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999995231628418, - 0.05000000074505806, - 0.05000007897615433, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999998211860657, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999998211860657, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000007897615433, - 0.05000000074505806, - 0.05000000074505806, - 1.0490009784698486, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999988153576851, - 0.05000011622905731, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.0592501237988472, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.1000003144145012, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999953210353851, - 0.10000000149011612, - 0.10000000149011612, - 0.10000047087669373, - 0.10000000149011612, - 0.10000000149011612, - 0.09999984502792358, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999984502792358, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000062733888626, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999906271696092, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10600025206804276, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.11999887973070145, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.11999887973070145, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.11999862641096117, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.119998499751091, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.11999925225973128, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.11999925225973128, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Home price=%{x}
    SDLT=%{y}", - "legendgroup": "", - "line": { - "color": "#636efa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "", - "orientation": "v", - "showlegend": false, - "type": "scatter", - "x": [ - 0, - 5005.0048828125, - 10010.009765625, - 15015.0146484375, - 20020.01953125, - 25025.025390625, - 30030.029296875, - 35035.03515625, - 40040.0390625, - 45045.046875, - 50050.05078125, - 55055.0546875, - 60060.05859375, - 65065.06640625, - 70070.0703125, - 75075.078125, - 80080.078125, - 85085.0859375, - 90090.09375, - 95095.09375, - 100100.1015625, - 105105.1015625, - 110110.109375, - 115115.1171875, - 120120.1171875, - 125125.125, - 130130.1328125, - 135135.140625, - 140140.140625, - 145145.140625, - 150150.15625, - 155155.15625, - 160160.15625, - 165165.171875, - 170170.171875, - 175175.171875, - 180180.1875, - 185185.1875, - 190190.1875, - 195195.1875, - 200200.203125, - 205205.203125, - 210210.203125, - 215215.21875, - 220220.21875, - 225225.21875, - 230230.234375, - 235235.234375, - 240240.234375, - 245245.25, - 250250.25, - 255255.25, - 260260.265625, - 265265.25, - 270270.28125, - 275275.28125, - 280280.28125, - 285285.28125, - 290290.28125, - 295295.28125, - 300300.3125, - 305305.3125, - 310310.3125, - 315315.3125, - 320320.3125, - 325325.3125, - 330330.34375, - 335335.34375, - 340340.34375, - 345345.34375, - 350350.34375, - 355355.34375, - 360360.375, - 365365.375, - 370370.375, - 375375.375, - 380380.375, - 385385.375, - 390390.375, - 395395.40625, - 400400.40625, - 405405.40625, - 410410.40625, - 415415.40625, - 420420.40625, - 425425.4375, - 430430.4375, - 435435.4375, - 440440.4375, - 445445.4375, - 450450.4375, - 455455.46875, - 460460.46875, - 465465.46875, - 470470.46875, - 475475.46875, - 480480.46875, - 485485.5, - 490490.5, - 495495.5, - 500500.5, - 505505.5, - 510510.5, - 515515.5, - 520520.53125, - 525525.5, - 530530.5, - 535535.5625, - 540540.5625, - 545545.5625, - 550550.5625, - 555555.5625, - 560560.5625, - 565565.5625, - 570570.5625, - 575575.5625, - 580580.5625, - 585585.5625, - 590590.5625, - 595595.625, - 600600.625, - 605605.625, - 610610.625, - 615615.625, - 620620.625, - 625625.625, - 630630.625, - 635635.625, - 640640.625, - 645645.625, - 650650.625, - 655655.625, - 660660.6875, - 665665.6875, - 670670.6875, - 675675.6875, - 680680.6875, - 685685.6875, - 690690.6875, - 695695.6875, - 700700.6875, - 705705.6875, - 710710.6875, - 715715.6875, - 720720.75, - 725725.75, - 730730.75, - 735735.75, - 740740.75, - 745745.75, - 750750.75, - 755755.75, - 760760.75, - 765765.75, - 770770.75, - 775775.75, - 780780.75, - 785785.8125, - 790790.8125, - 795795.8125, - 800800.8125, - 805805.8125, - 810810.8125, - 815815.8125, - 820820.8125, - 825825.8125, - 830830.8125, - 835835.8125, - 840840.8125, - 845845.875, - 850850.875, - 855855.875, - 860860.875, - 865865.875, - 870870.875, - 875875.875, - 880880.875, - 885885.875, - 890890.875, - 895895.875, - 900900.875, - 905905.875, - 910910.9375, - 915915.9375, - 920920.9375, - 925925.9375, - 930930.9375, - 935935.9375, - 940940.9375, - 945945.9375, - 950950.9375, - 955955.9375, - 960960.9375, - 965965.9375, - 970971, - 975976, - 980981, - 985986, - 990991, - 995996, - 1001001, - 1006006, - 1011011, - 1016016, - 1021021, - 1026026, - 1031031, - 1036036.0625, - 1041041.0625, - 1046046.0625, - 1051051, - 1056056, - 1061061, - 1066066.125, - 1071071.125, - 1076076.125, - 1081081.125, - 1086086.125, - 1091091.125, - 1096096.125, - 1101101.125, - 1106106.125, - 1111111.125, - 1116116.125, - 1121121.125, - 1126126.125, - 1131131.125, - 1136136.125, - 1141141.125, - 1146146.125, - 1151151.125, - 1156156.125, - 1161161.125, - 1166166.125, - 1171171.125, - 1176176.125, - 1181181.125, - 1186186.125, - 1191191.25, - 1196196.25, - 1201201.25, - 1206206.25, - 1211211.25, - 1216216.25, - 1221221.25, - 1226226.25, - 1231231.25, - 1236236.25, - 1241241.25, - 1246246.25, - 1251251.25, - 1256256.25, - 1261261.25, - 1266266.25, - 1271271.25, - 1276276.25, - 1281281.25, - 1286286.25, - 1291291.25, - 1296296.25, - 1301301.25, - 1306306.25, - 1311311.25, - 1316316.375, - 1321321.375, - 1326326.375, - 1331331.375, - 1336336.375, - 1341341.375, - 1346346.375, - 1351351.375, - 1356356.375, - 1361361.375, - 1366366.375, - 1371371.375, - 1376376.375, - 1381381.375, - 1386386.375, - 1391391.375, - 1396396.375, - 1401401.375, - 1406406.375, - 1411411.375, - 1416416.375, - 1421421.375, - 1426426.375, - 1431431.375, - 1436436.375, - 1441441.5, - 1446446.5, - 1451451.5, - 1456456.5, - 1461461.5, - 1466466.5, - 1471471.5, - 1476476.5, - 1481481.5, - 1486486.5, - 1491491.5, - 1496496.5, - 1501501.5, - 1506506.5, - 1511511.5, - 1516516.5, - 1521521.5, - 1526526.5, - 1531531.5, - 1536536.5, - 1541541.5, - 1546546.5, - 1551551.5, - 1556556.5, - 1561561.5, - 1566566.625, - 1571571.625, - 1576576.625, - 1581581.625, - 1586586.625, - 1591591.625, - 1596596.625, - 1601601.625, - 1606606.625, - 1611611.625, - 1616616.625, - 1621621.625, - 1626626.625, - 1631631.625, - 1636636.625, - 1641641.625, - 1646646.625, - 1651651.625, - 1656656.625, - 1661661.625, - 1666666.625, - 1671671.625, - 1676676.625, - 1681681.625, - 1686686.625, - 1691691.75, - 1696696.75, - 1701701.75, - 1706706.75, - 1711711.75, - 1716716.75, - 1721721.75, - 1726726.75, - 1731731.75, - 1736736.75, - 1741741.75, - 1746746.75, - 1751751.75, - 1756756.75, - 1761761.75, - 1766766.75, - 1771771.75, - 1776776.75, - 1781781.75, - 1786786.75, - 1791791.75, - 1796796.75, - 1801801.75, - 1806806.75, - 1811811.75, - 1816816.875, - 1821821.875, - 1826826.875, - 1831831.875, - 1836836.875, - 1841841.875, - 1846846.875, - 1851851.875, - 1856856.875, - 1861861.875, - 1866866.875, - 1871871.875, - 1876876.875, - 1881881.875, - 1886886.875, - 1891891.875, - 1896896.875, - 1901901.875, - 1906906.875, - 1911911.875, - 1916916.875, - 1921921.875, - 1926926.875, - 1931931.875, - 1936936.875, - 1941942, - 1946947, - 1951952, - 1956957, - 1961962, - 1966967, - 1971972, - 1976977, - 1981982, - 1986987, - 1991992, - 1996997, - 2002002, - 2007007, - 2012012, - 2017017, - 2022022, - 2027027, - 2032032, - 2037037, - 2042042, - 2047047, - 2052052, - 2057057, - 2062062, - 2067067.125, - 2072072.125, - 2077077.125, - 2082082.125, - 2087087.125, - 2092092.125, - 2097097.125, - 2102102, - 2107107, - 2112112, - 2117117, - 2122122, - 2127127.25, - 2132132.25, - 2137137.25, - 2142142.25, - 2147147.25, - 2152152.25, - 2157157.25, - 2162162.25, - 2167167.25, - 2172172.25, - 2177177.25, - 2182182.25, - 2187187.25, - 2192192.25, - 2197197.25, - 2202202.25, - 2207207.25, - 2212212.25, - 2217217.25, - 2222222.25, - 2227227.25, - 2232232.25, - 2237237.25, - 2242242.25, - 2247247.25, - 2252252.25, - 2257257.25, - 2262262.25, - 2267267.25, - 2272272.25, - 2277277.25, - 2282282.25, - 2287287.25, - 2292292.25, - 2297297.25, - 2302302.25, - 2307307.25, - 2312312.25, - 2317317.25, - 2322322.25, - 2327327.25, - 2332332.25, - 2337337.25, - 2342342.25, - 2347347.25, - 2352352.25, - 2357357.25, - 2362362.25, - 2367367.25, - 2372372.25, - 2377377.5, - 2382382.5, - 2387387.5, - 2392392.5, - 2397397.5, - 2402402.5, - 2407407.5, - 2412412.5, - 2417417.5, - 2422422.5, - 2427427.5, - 2432432.5, - 2437437.5, - 2442442.5, - 2447447.5, - 2452452.5, - 2457457.5, - 2462462.5, - 2467467.5, - 2472472.5, - 2477477.5, - 2482482.5, - 2487487.5, - 2492492.5, - 2497497.5, - 2502502.5, - 2507507.5, - 2512512.5, - 2517517.5, - 2522522.5, - 2527527.5, - 2532532.5, - 2537537.5, - 2542542.5, - 2547547.5, - 2552552.5, - 2557557.5, - 2562562.5, - 2567567.5, - 2572572.5, - 2577577.5, - 2582582.5, - 2587587.5, - 2592592.5, - 2597597.5, - 2602602.5, - 2607607.5, - 2612612.5, - 2617617.5, - 2622622.5, - 2627627.75, - 2632632.75, - 2637637.75, - 2642642.75, - 2647647.75, - 2652652.75, - 2657657.75, - 2662662.75, - 2667667.75, - 2672672.75, - 2677677.75, - 2682682.75, - 2687687.75, - 2692692.75, - 2697697.75, - 2702702.75, - 2707707.75, - 2712712.75, - 2717717.75, - 2722722.75, - 2727727.75, - 2732732.75, - 2737737.75, - 2742742.75, - 2747747.75, - 2752752.75, - 2757757.75, - 2762762.75, - 2767767.75, - 2772772.75, - 2777777.75, - 2782782.75, - 2787787.75, - 2792792.75, - 2797797.75, - 2802802.75, - 2807807.75, - 2812812.75, - 2817817.75, - 2822822.75, - 2827827.75, - 2832832.75, - 2837837.75, - 2842842.75, - 2847847.75, - 2852852.75, - 2857857.75, - 2862862.75, - 2867867.75, - 2872872.75, - 2877878, - 2882883, - 2887888, - 2892893, - 2897898, - 2902903, - 2907908, - 2912913, - 2917918, - 2922923, - 2927928, - 2932933, - 2937938, - 2942943, - 2947948, - 2952953, - 2957958, - 2962963, - 2967968, - 2972973, - 2977978, - 2982983, - 2987988, - 2992993, - 2997998, - 3003003, - 3008008, - 3013013, - 3018018, - 3023023, - 3028028, - 3033033, - 3038038, - 3043043, - 3048048, - 3053053, - 3058058, - 3063063, - 3068068, - 3073073, - 3078078, - 3083083, - 3088088, - 3093093, - 3098098, - 3103103, - 3108108, - 3113113, - 3118118, - 3123123, - 3128128.25, - 3133133.25, - 3138138.25, - 3143143.25, - 3148148.25, - 3153153.25, - 3158158.25, - 3163163.25, - 3168168.25, - 3173173.25, - 3178178.25, - 3183183.25, - 3188188.25, - 3193193.25, - 3198198.25, - 3203203.25, - 3208208.25, - 3213213.25, - 3218218.25, - 3223223.25, - 3228228.25, - 3233233.25, - 3238238.25, - 3243243.25, - 3248248.25, - 3253253.25, - 3258258.25, - 3263263.25, - 3268268.25, - 3273273.25, - 3278278.25, - 3283283.25, - 3288288.25, - 3293293.25, - 3298298.25, - 3303303.25, - 3308308.25, - 3313313.25, - 3318318.25, - 3323323.25, - 3328328.25, - 3333333.25, - 3338338.25, - 3343343.25, - 3348348.25, - 3353353.25, - 3358358.25, - 3363363.25, - 3368368.25, - 3373373.25, - 3378378.5, - 3383383.5, - 3388388.5, - 3393393.5, - 3398398.5, - 3403403.5, - 3408408.5, - 3413413.5, - 3418418.5, - 3423423.5, - 3428428.5, - 3433433.5, - 3438438.5, - 3443443.5, - 3448448.5, - 3453453.5, - 3458458.5, - 3463463.5, - 3468468.5, - 3473473.5, - 3478478.5, - 3483483.5, - 3488488.5, - 3493493.5, - 3498498.5, - 3503503.5, - 3508508.5, - 3513513.5, - 3518518.5, - 3523523.5, - 3528528.5, - 3533533.5, - 3538538.5, - 3543543.5, - 3548548.5, - 3553553.5, - 3558558.5, - 3563563.5, - 3568568.5, - 3573573.5, - 3578578.5, - 3583583.5, - 3588588.5, - 3593593.5, - 3598598.5, - 3603603.5, - 3608608.5, - 3613613.5, - 3618618.5, - 3623623.5, - 3628628.75, - 3633633.75, - 3638638.75, - 3643643.75, - 3648648.75, - 3653653.75, - 3658658.75, - 3663663.75, - 3668668.75, - 3673673.75, - 3678678.75, - 3683683.75, - 3688688.75, - 3693693.75, - 3698698.75, - 3703703.75, - 3708708.75, - 3713713.75, - 3718718.75, - 3723723.75, - 3728728.75, - 3733733.75, - 3738738.75, - 3743743.75, - 3748748.75, - 3753753.75, - 3758758.75, - 3763763.75, - 3768768.75, - 3773773.75, - 3778778.75, - 3783783.75, - 3788788.75, - 3793793.75, - 3798798.75, - 3803803.75, - 3808808.75, - 3813813.75, - 3818818.75, - 3823823.75, - 3828828.75, - 3833833.75, - 3838838.75, - 3843843.75, - 3848848.75, - 3853853.75, - 3858858.75, - 3863863.75, - 3868868.75, - 3873873.75, - 3878879, - 3883884, - 3888889, - 3893894, - 3898899, - 3903904, - 3908909, - 3913914, - 3918919, - 3923924, - 3928929, - 3933934, - 3938939, - 3943944, - 3948949, - 3953954, - 3958959, - 3963964, - 3968969, - 3973974, - 3978979, - 3983984, - 3988989, - 3993994, - 3998999, - 4004004, - 4009009, - 4014014, - 4019019, - 4024024, - 4029029, - 4034034, - 4039039, - 4044044, - 4049049, - 4054054, - 4059059, - 4064064, - 4069069, - 4074074, - 4079079, - 4084084, - 4089089, - 4094094, - 4099099, - 4104104, - 4109109, - 4114114, - 4119119, - 4124124, - 4129129.25, - 4134134.25, - 4139139.25, - 4144144.25, - 4149149.25, - 4154154.25, - 4159159.25, - 4164164.25, - 4169169.25, - 4174174.25, - 4179179.25, - 4184184.25, - 4189189.25, - 4194194.25, - 4199199, - 4204204, - 4209209, - 4214214, - 4219219, - 4224224, - 4229229, - 4234234, - 4239239, - 4244244, - 4249249, - 4254254.5, - 4259259.5, - 4264264.5, - 4269269.5, - 4274274.5, - 4279279.5, - 4284284.5, - 4289289.5, - 4294294.5, - 4299299.5, - 4304304.5, - 4309309.5, - 4314314.5, - 4319319.5, - 4324324.5, - 4329329.5, - 4334334.5, - 4339339.5, - 4344344.5, - 4349349.5, - 4354354.5, - 4359359.5, - 4364364.5, - 4369369.5, - 4374374.5, - 4379379.5, - 4384384.5, - 4389389.5, - 4394394.5, - 4399399.5, - 4404404.5, - 4409409.5, - 4414414.5, - 4419419.5, - 4424424.5, - 4429429.5, - 4434434.5, - 4439439.5, - 4444444.5, - 4449449.5, - 4454454.5, - 4459459.5, - 4464464.5, - 4469469.5, - 4474474.5, - 4479479.5, - 4484484.5, - 4489489.5, - 4494494.5, - 4499499.5, - 4504504.5, - 4509509.5, - 4514514.5, - 4519519.5, - 4524524.5, - 4529529.5, - 4534534.5, - 4539539.5, - 4544544.5, - 4549549.5, - 4554554.5, - 4559559.5, - 4564564.5, - 4569569.5, - 4574574.5, - 4579579.5, - 4584584.5, - 4589589.5, - 4594594.5, - 4599599.5, - 4604604.5, - 4609609.5, - 4614614.5, - 4619619.5, - 4624624.5, - 4629629.5, - 4634634.5, - 4639639.5, - 4644644.5, - 4649649.5, - 4654654.5, - 4659659.5, - 4664664.5, - 4669669.5, - 4674674.5, - 4679679.5, - 4684684.5, - 4689689.5, - 4694694.5, - 4699699.5, - 4704704.5, - 4709709.5, - 4714714.5, - 4719719.5, - 4724724.5, - 4729729.5, - 4734734.5, - 4739739.5, - 4744744.5, - 4749749.5, - 4754755, - 4759760, - 4764765, - 4769770, - 4774775, - 4779780, - 4784785, - 4789790, - 4794795, - 4799800, - 4804805, - 4809810, - 4814815, - 4819820, - 4824825, - 4829830, - 4834835, - 4839840, - 4844845, - 4849850, - 4854855, - 4859860, - 4864865, - 4869870, - 4874875, - 4879880, - 4884885, - 4889890, - 4894895, - 4899900, - 4904905, - 4909910, - 4914915, - 4919920, - 4924925, - 4929930, - 4934935, - 4939940, - 4944945, - 4949950, - 4954955, - 4959960, - 4964965, - 4969970, - 4974975, - 4979980, - 4984985, - 4989990, - 4994995 - ], - "xaxis": "x2", - "y": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 15.015625, - 265.265625, - 515.515625, - 765.765625, - 1016.015625, - 1266.265625, - 1516.5172119140625, - 1766.7672119140625, - 2017.0172119140625, - 2267.26708984375, - 2517.51708984375, - 2767.76708984375, - 3018.018798828125, - 3268.268798828125, - 3518.518798828125, - 3768.768798828125, - 4019.018798828125, - 4269.2685546875, - 4519.5185546875, - 4769.7705078125, - 5020.0205078125, - 5270.2705078125, - 5520.5205078125, - 5770.7705078125, - 6021.0205078125, - 6271.27197265625, - 6521.52197265625, - 6771.77197265625, - 7022.02197265625, - 7272.27197265625, - 7522.52197265625, - 7772.7734375, - 8023.0234375, - 8273.2734375, - 8523.5234375, - 8773.7734375, - 9024.0234375, - 9274.275390625, - 9524.525390625, - 9774.775390625, - 15025.025390625, - 15275.275390625, - 15525.525390625, - 15775.775390625, - 16026.0263671875, - 16276.275390625, - 16526.525390625, - 16776.77734375, - 17027.02734375, - 17277.27734375, - 17527.52734375, - 17777.77734375, - 18028.02734375, - 18278.27734375, - 18528.52734375, - 18778.77734375, - 19029.02734375, - 19279.27734375, - 19529.52734375, - 19779.78125, - 20030.03125, - 20280.28125, - 20530.53125, - 20780.78125, - 21031.03125, - 21281.28125, - 21531.53125, - 21781.78125, - 22032.03125, - 22282.28125, - 22532.53125, - 22782.78125, - 23033.03515625, - 23283.28515625, - 23533.53515625, - 23783.78515625, - 24034.03515625, - 24284.28515625, - 24534.53515625, - 24784.78515625, - 25035.03515625, - 25285.28515625, - 25535.53515625, - 25785.78515625, - 26036.037109375, - 26286.287109375, - 26536.537109375, - 26786.787109375, - 27037.037109375, - 27287.287109375, - 27537.537109375, - 27787.787109375, - 28038.037109375, - 28288.287109375, - 28538.537109375, - 28788.787109375, - 29039.037109375, - 29289.291015625, - 29539.541015625, - 29789.791015625, - 30040.041015625, - 30290.291015625, - 30540.541015625, - 30790.791015625, - 31041.041015625, - 31291.291015625, - 31541.541015625, - 31791.791015625, - 32042.041015625, - 32292.29296875, - 32542.54296875, - 32792.79296875, - 33043.04296875, - 33293.29296875, - 33543.54296875, - 33793.79296875, - 34044.04296875, - 34294.29296875, - 34544.54296875, - 34794.79296875, - 35045.04296875, - 35295.29296875, - 35545.546875, - 35795.796875, - 36046.046875, - 36342.59375, - 36843.09375, - 37343.59375, - 37844.09375, - 38344.59375, - 38845.09375, - 39345.59375, - 39846.09375, - 40346.59375, - 40847.1015625, - 41347.6015625, - 41848.1015625, - 42348.6015625, - 42849.1015625, - 43349.6015625, - 43850.1015625, - 44350.6015625, - 44851.1015625, - 45351.6015625, - 45852.1015625, - 46352.6015625, - 46853.1015625, - 47353.60546875, - 47854.10546875, - 48354.60546875, - 48855.1015625, - 49355.6015625, - 49856.1015625, - 50356.61328125, - 50857.11328125, - 51357.61328125, - 51858.11328125, - 52358.61328125, - 52859.11328125, - 53359.61328125, - 53860.11328125, - 54360.61328125, - 54861.11328125, - 55361.61328125, - 55862.11328125, - 56362.61328125, - 56863.11328125, - 57363.61328125, - 57864.11328125, - 58364.61328125, - 58865.11328125, - 59365.61328125, - 59866.11328125, - 60366.61328125, - 60867.11328125, - 61367.61328125, - 61868.11328125, - 62368.61328125, - 62869.125, - 63369.625, - 63870.125, - 64370.625, - 64871.125, - 65371.625, - 65872.125, - 66372.625, - 66873.125, - 67373.625, - 67874.125, - 68374.625, - 68875.125, - 69375.625, - 69876.125, - 70376.625, - 70877.125, - 71377.625, - 71878.125, - 72378.625, - 72879.125, - 73379.625, - 73880.125, - 74380.625, - 74881.125, - 75381.640625, - 75882.140625, - 76382.640625, - 76883.140625, - 77383.640625, - 77884.140625, - 78384.640625, - 78885.140625, - 79385.640625, - 79886.140625, - 80386.640625, - 80887.140625, - 81387.640625, - 81888.140625, - 82388.640625, - 82889.140625, - 83389.640625, - 83890.140625, - 84390.640625, - 84891.140625, - 85391.640625, - 85892.140625, - 86392.640625, - 86893.140625, - 87393.640625, - 87894.1484375, - 88394.6484375, - 88895.1484375, - 89395.6484375, - 89896.1484375, - 90396.6484375, - 90897.1484375, - 91397.6484375, - 91898.1484375, - 92398.6484375, - 92899.1484375, - 93399.6484375, - 93930.1796875, - 94530.78125, - 95131.3828125, - 95731.9765625, - 96332.578125, - 96933.1796875, - 97533.78125, - 98134.3828125, - 98734.9765625, - 99335.578125, - 99936.1796875, - 100536.78125, - 101137.3828125, - 101737.9921875, - 102338.59375, - 102939.1953125, - 103539.796875, - 104140.3984375, - 104740.9921875, - 105341.59375, - 105942.1953125, - 106542.796875, - 107143.3984375, - 107743.9921875, - 108344.59375, - 108945.1953125, - 109545.796875, - 110146.3984375, - 110746.9921875, - 111347.59375, - 111948.1953125, - 112548.796875, - 113149.3984375, - 113749.9921875, - 114350.59375, - 114951.1953125, - 115551.796875, - 116152.3984375, - 116753.0078125, - 117353.609375, - 117954.2109375, - 118554.8125, - 119155.40625, - 119756.0078125, - 120356.609375, - 120957.2109375, - 121557.8125, - 122158.40625, - 122759.0078125, - 123359.609375, - 123960.2109375, - 124560.8125, - 125161.40625, - 125762.0078125, - 126362.609375, - 126963.2109375, - 127563.8125, - 128164.40625, - 128765.0078125, - 129365.609375, - 129966.2109375, - 130566.8125, - 131167.40625, - 131768.03125, - 132368.625, - 132969.21875, - 133569.828125, - 134170.421875, - 134771.03125, - 135371.625, - 135972.21875, - 136572.828125, - 137173.421875, - 137774.03125, - 138374.625, - 138975.21875, - 139575.828125, - 140176.421875, - 140777.03125, - 141377.625, - 141978.21875, - 142578.828125, - 143179.421875, - 143780.03125, - 144380.625, - 144981.21875, - 145581.828125, - 146182.421875, - 146783.046875, - 147383.640625, - 147984.234375, - 148584.84375, - 149185.4375, - 149786.046875, - 150386.640625, - 150987.234375, - 151587.84375, - 152188.4375, - 152789.046875, - 153389.640625, - 153990.234375, - 154590.84375, - 155191.4375, - 155792.046875, - 156392.640625, - 156993.234375, - 157593.84375, - 158194.4375, - 158795.046875, - 159395.640625, - 159996.234375, - 160596.84375, - 161197.4375, - 161798.0625, - 162398.65625, - 162999.25, - 163599.859375, - 164200.453125, - 164801.0625, - 165401.65625, - 166002.234375, - 166602.84375, - 167203.4375, - 167804.046875, - 168404.640625, - 169005.265625, - 169605.875, - 170206.46875, - 170807.0625, - 171407.671875, - 172008.265625, - 172608.875, - 173209.46875, - 173810.0625, - 174410.671875, - 175011.265625, - 175611.875, - 176212.46875, - 176813.0625, - 177413.671875, - 178014.265625, - 178614.875, - 179215.46875, - 179816.0625, - 180416.671875, - 181017.265625, - 181617.875, - 182218.46875, - 182819.0625, - 183419.671875, - 184020.265625, - 184620.875, - 185221.46875, - 185822.0625, - 186422.671875, - 187023.265625, - 187623.875, - 188224.46875, - 188825.0625, - 189425.671875, - 190026.265625, - 190626.875, - 191227.46875, - 191828.0625, - 192428.671875, - 193029.265625, - 193629.875, - 194230.46875, - 194831.0625, - 195431.671875, - 196032.265625, - 196632.875, - 197233.46875, - 197834.0625, - 198434.671875, - 199035.296875, - 199635.90625, - 200236.5, - 200837.09375, - 201437.703125, - 202038.296875, - 202638.90625, - 203239.5, - 203840.09375, - 204440.703125, - 205041.296875, - 205641.90625, - 206242.5, - 206843.09375, - 207443.703125, - 208044.296875, - 208644.90625, - 209245.5, - 209846.09375, - 210446.703125, - 211047.296875, - 211647.90625, - 212248.5, - 212849.09375, - 213449.703125, - 214050.296875, - 214650.90625, - 215251.5, - 215852.09375, - 216452.703125, - 217053.296875, - 217653.90625, - 218254.5, - 218855.09375, - 219455.703125, - 220056.296875, - 220656.90625, - 221257.5, - 221858.09375, - 222458.703125, - 223059.296875, - 223659.90625, - 224260.5, - 224861.09375, - 225461.703125, - 226062.296875, - 226662.90625, - 227263.5, - 227864.09375, - 228464.703125, - 229065.328125, - 229665.9375, - 230266.53125, - 230867.125, - 231467.734375, - 232068.328125, - 232668.9375, - 233269.53125, - 233870.125, - 234470.734375, - 235071.328125, - 235671.9375, - 236272.53125, - 236873.125, - 237473.734375, - 238074.328125, - 238674.9375, - 239275.53125, - 239876.125, - 240476.734375, - 241077.328125, - 241677.9375, - 242278.53125, - 242879.125, - 243479.734375, - 244080.328125, - 244680.9375, - 245281.53125, - 245882.125, - 246482.734375, - 247083.328125, - 247683.9375, - 248284.53125, - 248885.125, - 249485.734375, - 250086.328125, - 250686.9375, - 251287.53125, - 251888.125, - 252488.734375, - 253089.328125, - 253689.9375, - 254290.53125, - 254891.125, - 255491.734375, - 256092.328125, - 256692.9375, - 257293.53125, - 257894.125, - 258494.734375, - 259095.359375, - 259695.953125, - 260296.5625, - 260897.15625, - 261497.765625, - 262098.359375, - 262698.96875, - 263299.5625, - 263900.15625, - 264500.75, - 265101.375, - 265701.96875, - 266302.5625, - 266903.15625, - 267503.75, - 268104.375, - 268704.96875, - 269305.5625, - 269906.15625, - 270506.75, - 271107.375, - 271707.96875, - 272308.5625, - 272909.15625, - 273509.75, - 274110.375, - 274710.96875, - 275311.5625, - 275912.15625, - 276512.75, - 277113.375, - 277713.96875, - 278314.5625, - 278915.15625, - 279515.75, - 280116.375, - 280716.96875, - 281317.5625, - 281918.15625, - 282518.75, - 283119.375, - 283719.96875, - 284320.5625, - 284921.15625, - 285521.75, - 286122.375, - 286722.96875, - 287323.5625, - 287924.15625, - 288524.75, - 289125.375, - 289726, - 290326.59375, - 290927.1875, - 291527.78125, - 292128.375, - 292729, - 293329.59375, - 293930.1875, - 294530.78125, - 295131.375, - 295732, - 296332.59375, - 296933.1875, - 297533.78125, - 298134.375, - 298735, - 299335.59375, - 299936.1875, - 300536.78125, - 301137.375, - 301738, - 302338.59375, - 302939.1875, - 303539.78125, - 304140.375, - 304741, - 305341.59375, - 305942.1875, - 306542.78125, - 307143.375, - 307744, - 308344.59375, - 308945.1875, - 309545.78125, - 310146.375, - 310747, - 311347.59375, - 311948.1875, - 312548.78125, - 313149.375, - 313750, - 314350.59375, - 314951.1875, - 315551.78125, - 316152.375, - 316753, - 317353.59375, - 317954.1875, - 318554.78125, - 319155.40625, - 319756.03125, - 320356.625, - 320957.21875, - 321557.8125, - 322158.40625, - 322759.03125, - 323359.625, - 323960.21875, - 324560.8125, - 325161.40625, - 325762.03125, - 326362.625, - 326963.21875, - 327563.8125, - 328164.40625, - 328765.03125, - 329365.625, - 329966.21875, - 330566.8125, - 331167.40625, - 331768.03125, - 332368.625, - 332969.21875, - 333569.8125, - 334170.40625, - 334771.03125, - 335371.625, - 335972.21875, - 336572.8125, - 337173.40625, - 337774.03125, - 338374.625, - 338975.21875, - 339575.8125, - 340176.40625, - 340777.03125, - 341377.625, - 341978.21875, - 342578.8125, - 343179.40625, - 343780.03125, - 344380.625, - 344981.21875, - 345581.8125, - 346182.40625, - 346783.03125, - 347383.625, - 347984.21875, - 348584.8125, - 349185.4375, - 349786.0625, - 350386.65625, - 350987.25, - 351587.84375, - 352188.4375, - 352789.0625, - 353389.65625, - 353990.25, - 354590.84375, - 355191.4375, - 355792.0625, - 356392.65625, - 356993.25, - 357593.84375, - 358194.4375, - 358795.0625, - 359395.65625, - 359996.25, - 360596.84375, - 361197.4375, - 361798.0625, - 362398.65625, - 362999.25, - 363599.84375, - 364200.4375, - 364801.0625, - 365401.65625, - 366002.25, - 366602.84375, - 367203.4375, - 367804.0625, - 368404.65625, - 369005.25, - 369605.84375, - 370206.4375, - 370807.0625, - 371407.65625, - 372008.25, - 372608.84375, - 373209.4375, - 373810.0625, - 374410.65625, - 375011.25, - 375611.84375, - 376212.4375, - 376813.0625, - 377413.65625, - 378014.25, - 378614.84375, - 379215.46875, - 379816.09375, - 380416.6875, - 381017.28125, - 381617.875, - 382218.46875, - 382819.09375, - 383419.6875, - 384020.28125, - 384620.875, - 385221.46875, - 385822.09375, - 386422.6875, - 387023.28125, - 387623.875, - 388224.46875, - 388825.09375, - 389425.6875, - 390026.28125, - 390626.875, - 391227.46875, - 391828.09375, - 392428.6875, - 393029.28125, - 393629.875, - 394230.46875, - 394831.09375, - 395431.6875, - 396032.28125, - 396632.875, - 397233.46875, - 397834.09375, - 398434.6875, - 399035.28125, - 399635.875, - 400236.46875, - 400837.09375, - 401437.6875, - 402038.28125, - 402638.875, - 403239.46875, - 403840.09375, - 404440.6875, - 405041.28125, - 405641.875, - 406242.46875, - 406843.09375, - 407443.6875, - 408044.28125, - 408644.875, - 409245.5, - 409846.125, - 410446.71875, - 411047.3125, - 411647.90625, - 412248.5, - 412849.125, - 413449.71875, - 414050.3125, - 414650.90625, - 415251.5, - 415852.125, - 416452.71875, - 417053.3125, - 417653.875, - 418254.46875, - 418855.09375, - 419455.6875, - 420056.28125, - 420656.875, - 421257.46875, - 421858.09375, - 422458.6875, - 423059.28125, - 423659.875, - 424260.53125, - 424861.125, - 425461.75, - 426062.34375, - 426662.9375, - 427263.53125, - 427864.125, - 428464.75, - 429065.34375, - 429665.9375, - 430266.53125, - 430867.125, - 431467.75, - 432068.34375, - 432668.9375, - 433269.53125, - 433870.125, - 434470.75, - 435071.34375, - 435671.9375, - 436272.53125, - 436873.125, - 437473.75, - 438074.34375, - 438674.9375, - 439275.53125, - 439876.125, - 440476.75, - 441077.34375, - 441677.9375, - 442278.53125, - 442879.125, - 443479.75, - 444080.34375, - 444680.9375, - 445281.53125, - 445882.125, - 446482.75, - 447083.34375, - 447683.9375, - 448284.53125, - 448885.125, - 449485.75, - 450086.34375, - 450686.9375, - 451287.53125, - 451888.125, - 452488.75, - 453089.34375, - 453689.9375, - 454290.53125, - 454891.125, - 455491.75, - 456092.34375, - 456692.9375, - 457293.53125, - 457894.125, - 458494.75, - 459095.34375, - 459695.9375, - 460296.53125, - 460897.125, - 461497.75, - 462098.34375, - 462698.9375, - 463299.53125, - 463900.125, - 464500.75, - 465101.34375, - 465701.9375, - 466302.53125, - 466903.125, - 467503.75, - 468104.34375, - 468704.9375, - 469305.53125, - 469906.125, - 470506.75, - 471107.34375, - 471707.9375, - 472308.53125, - 472909.125, - 473509.75, - 474110.34375, - 474710.9375, - 475311.53125, - 475912.125, - 476512.75, - 477113.34375, - 477713.9375, - 478314.53125, - 478915.125, - 479515.75, - 480116.34375, - 480716.9375, - 481317.53125, - 481918.125, - 482518.75, - 483119.34375, - 483719.9375, - 484320.59375, - 484921.1875, - 485521.8125, - 486122.40625, - 486723, - 487323.59375, - 487924.1875, - 488524.8125, - 489125.40625, - 489726, - 490326.59375, - 490927.1875, - 491527.8125, - 492128.40625, - 492729, - 493329.59375, - 493930.1875, - 494530.8125, - 495131.40625, - 495732, - 496332.59375, - 496933.1875, - 497533.8125, - 498134.40625, - 498735, - 499335.59375, - 499936.1875, - 500536.8125, - 501137.40625, - 501738, - 502338.59375, - 502939.1875, - 503539.8125, - 504140.40625, - 504741, - 505341.59375, - 505942.1875, - 506542.8125, - 507143.40625, - 507744, - 508344.59375, - 508945.1875, - 509545.8125, - 510146.40625, - 510747, - 511347.59375, - 511948.1875, - 512548.8125, - 513149.40625 - ], - "yaxis": "y2" - } - ], - "layout": { - "annotations": [ - { - "font": { - "size": 16 - }, - "showarrow": false, - "text": "Marginal SDLT rate", - "x": 0.225, - "xanchor": "center", - "xref": "paper", - "y": 1, - "yanchor": "bottom", - "yref": "paper" - }, - { - "font": { - "size": 16 - }, - "showarrow": false, - "text": "Total SDLT", - "x": 0.775, - "xanchor": "center", - "xref": "paper", - "y": 1, - "yanchor": "bottom", - "yref": "paper" - } - ], - "font": { - "color": "black", - "family": "Roboto Serif" - }, - "height": 600, - "images": [ - { - "sizex": 0.2, - "sizey": 0.2, - "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", - "x": 1, - "xanchor": "right", - "xref": "paper", - "y": -0.15, - "yanchor": "bottom", - "yref": "paper" - } - ], - "modebar": { - "bgcolor": "rgba(0,0,0,0)", - "color": "rgba(0,0,0,0)" - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "heatmapgl": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmapgl" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "white", - "showlakes": true, - "showland": true, - "subunitcolor": "#C8D4E3" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "white", - "polar": { - "angularaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - }, - "bgcolor": "white", - "radialaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "yaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "zaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "baxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "bgcolor": "white", - "caxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - } - } - }, - "width": 800, - "xaxis": { - "anchor": "y", - "domain": [ - 0, - 0.45 - ], - "tickprefix": "£", - "title": { - "text": "Price" - } - }, - "xaxis2": { - "anchor": "y2", - "domain": [ - 0.55, - 1 - ], - "tickprefix": "£", - "title": { - "text": "Price" - } - }, - "yaxis": { - "anchor": "x", - "domain": [ - 0, - 1 - ], - "tickformat": ",.0%" - }, - "yaxis2": { - "anchor": "x2", - "domain": [ - 0, - 1 - ], - "tickprefix": "£" - } - } - } - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "## Residential purchase schedules\n\nThis model covers recent ordinary individual purchases in England and Northern Ireland. An additional-purchase input represents a transaction already classified as subject to the higher rates; the model does not derive that classification from ownership history.\n\n| Effective date | Standard nil band | First-time-buyer nil band | Inclusive first-time-buyer maximum | Additional-dwelling surcharge |\n| --- | ---: | ---: | ---: | ---: |\n| 23 September 2022–30 October 2024 | £250,000 | £425,000 | £625,000 | 3 percentage points |\n| 31 October 2024–31 March 2025 | £250,000 | £425,000 | £625,000 | 5 percentage points |\n| From 1 April 2025 | £125,000 | £300,000 | £500,000 | 5 percentage points |\n\nAbove the temporary £250,000 standard nil band, marginal rates are 5% up to £925,000, 10% up to £1.5 million and 12% thereafter. From 1 April 2025, the £125,000–£250,000 band returns at 2%; higher standard bands remain the same. First-time buyers pay 5% above their nil band **up to and including** the maximum purchase price. A purchase above that maximum loses first-time-buyer relief on the whole transaction.\n\nFor additional dwellings, the full higher-rates schedule applies when consideration is at least £40,000; below that amount the modeled additional purchase pays zero. During the temporary period its rates are 3/8/13/15%, then 5/10/15/17% from 31 October 2024. From 1 April 2025 the restored £125,000 band gives rates of 5/7/10/15/17%. There is no 7% band during the temporary £250,000 period.\n\nSources: [SDLT (Temporary Relief) Act 2023, section 1](https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted), [Finance Act 2025, sections 50–52](https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted), and [Finance Act 2003, Schedule 6ZA](https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA).\n\n### Annual simulation convention and limits\n\nParameters retain those legal dates, but annual simulations sample **30 April** of each year. Thus 2022 uses the pre-September bands, 2023 and 2024 use the temporary bands with the three-point surcharge, and 2025 uses restored bands with the five-point surcharge. A YEAR purchase input cannot select a transaction day within that year.\n\nThe higher-rate changes protect some contracts entered into before 31 October 2024, with exclusions for specified variations, assignments, options and subsales. Substantial-performance rules can also affect liability. Existing inputs cannot select those contract protections: the schedules above describe ordinary **unprotected** purchases. Earlier 2020/2021 holiday coverage and older history remain incomplete. This purchase update does not certify rental, lease, corporate, nonresident, ownership-classification or return-rounding rules, or estimate population impacts.\n\nThe following chart illustrates the **2023** marginal schedules. Its first-home rate curve does not show the discrete loss of relief above the maximum; the cash example below does.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], "source": [ - "from policyengine_uk import Simulation\n", - "\n", - "sim = Simulation(\n", - " situation=dict(\n", - " households=dict(\n", - " household=dict(\n", - " main_residential_property_purchased_is_first_home=True,\n", - " members=[\"person\"],\n", - " )\n", - " ),\n", - " people=dict(\n", - " person=dict(\n", - " age=30,\n", - " )\n", - " ),\n", - " axes=[\n", - " [\n", - " dict(\n", - " name=\"main_residential_property_purchased\",\n", - " min=0,\n", - " max=5_000_000,\n", - " count=1_000,\n", - " )\n", - " ]\n", - " ],\n", - " )\n", - ")\n", - "\n", "import pandas as pd\n", + "import plotly.express as px\n", + "from policyengine_core.charts import format_fig\n", "\n", - "stamp_duty = sim.calculate(\"stamp_duty_land_tax\")\n", - "home_price = sim.calculate(\"main_residential_property_purchased\")\n", - "\n", - "marginal_rate = (stamp_duty[1:] - stamp_duty[:-1]) / (home_price[1:] - home_price[:-1])\n", - "home_price = home_price[:-1]\n", - "\n", - "df = pd.DataFrame(\n", + "residential_rent = pd.DataFrame(\n", " {\n", - " \"Home price\": home_price,\n", - " \"Marginal SDLT rate\": marginal_rate,\n", - " \"SDLT\": stamp_duty[:-1],\n", + " \"Threshold\": [0, 125000, 5200000],\n", + " \"Rate\": [0, 0.01, 0.01],\n", + " \"Label\": [\"Residential rent\"] * 3,\n", " }\n", ")\n", - "\n", - "import plotly.express as px\n", - "from plotly.subplots import make_subplots\n", - "\n", - "# left shows marginal rate, right shows total SDLT\n", - "\n", - "fig = make_subplots(rows=1, cols=2, subplot_titles=(\"Marginal SDLT rate\", \"Total SDLT\"))\n", - "\n", - "fig.add_trace(\n", - " px.line(df, x=\"Home price\", y=\"Marginal SDLT rate\", title=\"Marginal SDLT rate\")\n", - " .update_traces(line_shape=\"hv\")\n", - " .data[0],\n", - " row=1,\n", - " col=1,\n", - ").update_xaxes(row=1, col=1, title_text=\"Price\", tickprefix=\"£\").update_yaxes(\n", - " row=1, col=1, tickformat=\",.0%\"\n", + "residential_purchase_main_first = pd.DataFrame(\n", + " {\n", + " \"Threshold\": [0, 425000, 625000, 925000, 1500000, 5200000],\n", + " \"Rate\": [0, 0.05, 0.05, 0.1, 0.12, 0.12],\n", + " \"Label\": [\"2023 residential purchase main (first home)\"] * 6,\n", + " }\n", ")\n", - "\n", - "fig.add_trace(\n", - " px.line(df, x=\"Home price\", y=\"SDLT\", title=\"Total SDLT\")\n", + "residential_purchase_main_standard = pd.DataFrame(\n", + " {\n", + " \"Threshold\": [0, 250000, 925000, 1500000, 5200000],\n", + " \"Rate\": [0, 0.05, 0.1, 0.12, 0.12],\n", + " \"Label\": [\"2023 residential purchase main standard\"] * 5,\n", + " }\n", + ")\n", + "residential_purchase_additional = pd.DataFrame(\n", + " {\n", + " \"Threshold\": [0, 40000, 250000, 925000, 1500000, 5200000],\n", + " \"Rate\": [0, 0.03, 0.08, 0.13, 0.15, 0.15],\n", + " \"Label\": [\"2023 residential purchase additional\"] * 6,\n", + " }\n", + ")\n", + "non_residential_rent = pd.DataFrame(\n", + " {\n", + " \"Threshold\": [0, 150000, 5000000, 5200000],\n", + " \"Rate\": [0, 0.01, 0.02, 0.02],\n", + " \"Label\": [\"Non-residential rent\"] * 4,\n", + " }\n", + ")\n", + "non_residential_purchase = pd.DataFrame(\n", + " {\n", + " \"Threshold\": [0, 150000, 250000, 5200000],\n", + " \"Rate\": [0, 0.02, 0.05, 0.05],\n", + " \"Label\": [\"Non-residential purchase\"] * 4,\n", + " }\n", + ")\n", + "df_list = [\n", + " residential_rent,\n", + " residential_purchase_main_first,\n", + " residential_purchase_main_standard,\n", + " residential_purchase_additional,\n", + " non_residential_rent,\n", + " non_residential_purchase,\n", + "]\n", + "df = pd.concat(df_list)\n", + "# plot\n", + "fig = (\n", + " px.line(\n", + " df,\n", + " x=\"Threshold\",\n", + " y=\"Rate\",\n", + " color=\"Label\",\n", + " title=\"Stamp duty land tax rates over property price thresholds\",\n", + " )\n", + " .update_layout(\n", + " yaxis_tickformat=\",.0%\",\n", + " xaxis_tickprefix=\"£\",\n", + " legend=dict(\n", + " orientation=\"h\",\n", + " yanchor=\"bottom\",\n", + " y=-0.3,\n", + " xanchor=\"right\",\n", + " x=0.9,\n", + " title=\"\",\n", + " ),\n", + " )\n", " .update_traces(line_shape=\"hv\")\n", - " .data[0],\n", - " row=1,\n", - " col=2,\n", - ").update_xaxes(row=1, col=2, title_text=\"Price\", tickprefix=\"£\").update_yaxes(\n", - " row=1, col=2, tickprefix=\"£\"\n", ")\n", - "\n", "fig = format_fig(fig)\n", "fig" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Inclusive cap and loss of relief\n\nFor an otherwise qualifying first-time buyer in the 2023 annual simulation, a £625,000 purchase pays £10,000: (£625,000 − £425,000) × 5%. At £625,020 the purchase exceeds the inclusive maximum and instead pays ordinary SDLT of £18,751: (£625,020 − £250,000) × 5%. These are simple unprotected purchases with all other relief conditions satisfied.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [], + "source": [ + "from policyengine_uk import Simulation\n", + "\n", + "prices = [625000, 625020]\n", + "situation = {\"people\": {}, \"benunits\": {}, \"households\": {}}\n", + "for i, price in enumerate(prices):\n", + " person = f\"buyer{i}\"\n", + " situation[\"people\"][person] = {\"age\": {2023: 30}}\n", + " situation[\"benunits\"][f\"family{i}\"] = {\"members\": [person]}\n", + " situation[\"households\"][f\"home{i}\"] = {\n", + " \"members\": [person],\n", + " \"region\": {2023: \"LONDON\"},\n", + " \"main_residential_property_purchased\": {2023: price},\n", + " \"main_residential_property_purchased_is_first_home\": {2023: True},\n", + " }\n", + "sim = Simulation(situation=situation)\n", + "pd.DataFrame(\n", + " {\n", + " \"Purchase price\": prices,\n", + " \"SDLT\": sim.calculate(\"stamp_duty_land_tax\", 2023),\n", + " }\n", + ")" + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml index e07522ab16..995f53048a 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml @@ -2,28 +2,36 @@ description: Stamp Duty Land Tax progressive rates for residential property tran brackets: - rate: 2016-09-15: 0.03 + 2024-10-31: 0.05 threshold: 2016-09-15: 0 - rate: 2016-09-15: 0.05 2020-07-08: null 2021-03-31: 0.05 + 2022-09-23: null + 2025-04-01: 0.07 threshold: 2016-09-15: 125_000 2020-07-08: null 2021-03-31: 125_000 + 2022-09-23: null + 2025-04-01: 125_000 - rate: 2016-09-15: 0.08 + 2024-10-31: 0.10 threshold: 2016-09-15: 250_000 2020-07-08: 500_000 2021-03-31: 250_000 - rate: 2016-09-15: 0.13 + 2024-10-31: 0.15 threshold: 2016-09-15: 925_000 - rate: 2016-09-15: 0.15 + 2024-10-31: 0.17 threshold: 2016-09-15: 1_500_000 metadata: @@ -35,3 +43,9 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted + - title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(3) + href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + - title: Finance Act 2025 s. 50 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted + - title: Finance Act 2025 s. 51 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/51/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml index e525df8d8c..1179796e47 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml @@ -1,9 +1,13 @@ description: Maximum value for a first-property purchase to receive the Stamp Duty relief for first-time buyers. Increasing this value will underestimate the number of claims for FTBR, as the model will not add new claims. values: 2018-03-15: 500_000 + 2022-09-23: 625_000 + 2025-04-01: 500_000 metadata: unit: currency-GBP label: Stamp Duty first home value limit reference: - title: Finance Act 2003 schedule 6ZA(1)(3) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 + - title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(5) + href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml index 920d1788c0..8033317913 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml @@ -7,6 +7,8 @@ brackets: 2018-03-15: 0.05 threshold: 2018-03-15: 300000 + 2022-09-23: 425000 + 2025-04-01: 300000 description: Stamp Duty Land Tax progressive rates for residential property transfers. metadata: economy: false @@ -16,4 +18,6 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 title: Finance Act 2003 schedule 6ZA(4) + - href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(5) threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml index 8b790e52a6..f285c30842 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml @@ -8,12 +8,16 @@ brackets: 2015-02-12: 0.02 2020-07-08: null 2021-03-31: 0.02 + 2022-09-23: null + 2025-04-01: 0.02 threshold: 2003-07-10: 60000 2005-04-07: 120000 2006-07-19: 125000 2020-07-08: null 2021-03-31: 125000 + 2022-09-23: null + 2025-04-01: 125000 - rate: 2003-07-10: 0.03 2015-02-12: 0.05 @@ -50,4 +54,6 @@ metadata: title: Finance Act 2003 s. 55 - href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted title: Stamp Duty Land Tax (Temporary Relief) Act 2020 + - href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1 threshold_unit: currency-GBP diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml index 0fb76a6d56..68b1057fb6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml @@ -20,13 +20,13 @@ main_residential_property_purchased_is_first_home: true output: stamp_duty_land_tax: 7_250 -- name: £500k first-time buyer (just over limit) +- name: £500,020 first-time buyer (over the £500k limit) period: 2022 input: - main_residential_property_purchased: 500_000.01 + main_residential_property_purchased: 500_020 main_residential_property_purchased_is_first_home: true output: - stamp_duty_land_tax: 15_000 + stamp_duty_land_tax: 15_001 - name: £500k non-first-time buyer period: 2022 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml new file mode 100644 index 0000000000..5344590020 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml @@ -0,0 +1,150 @@ +# Ordinary, unprotected purchases; annual parameters sample 30 April. +# Sources: SDLT (Temporary Relief) Act 2023 s1; Finance Act 2025 ss50-51; +# Finance Act 2003 s55 and Sch6ZA. No transaction-date or contract selector. +- name: Annual 2022 retains the pre-September standard schedule + period: 2022 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300000 + output: + stamp_duty_land_tax: 5000 + +- name: Annual 2023 main-home purchase uses temporary standard bands + period: 2023 + absolute_error_margin: 0.01 + input: + main_residence_value: 300000 + property_purchased: true + output: + stamp_duty_land_tax: 2500 + main_residential_property_purchased: 300000 + +- name: Temporary first-time-buyer maximum is inclusive + period: 2024 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 625000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 10000 + +- name: Restored first-time-buyer maximum is inclusive + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 500000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 10000 + +- name: Annual 2025 additional purchase uses restored bands with five-point surcharge + period: 2025 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 30000 + +- name: Annual 2024 still samples the three-point additional surcharge + period: 2024 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 19500 + +- name: First-time purchase above temporary maximum loses all relief + period: 2024 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 625020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 18751 + +- name: First-time purchase above restored maximum loses all relief + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 500020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 15001 + +- name: Northern Ireland combines independent temporary main and additional purchases + period: 2024 + absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + main_residential_property_purchased: 925000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 53250 + +- name: An additional purchase below forty thousand is outside the higher schedule + period: 2025 + input: + additional_residential_property_purchased: 39999 + output: + stamp_duty_land_tax: 0 + +- name: An additional purchase at forty thousand pays the full higher schedule + period: 2025 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 40000 + output: + stamp_duty_land_tax: 2000 + +- name: Restored purchase schedules persist in later annual simulations + period: 2027 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 35000 + +- name: Property stocks without a purchase do not incur SDLT + period: 2025 + input: + main_residence_value: 600000 + property_purchased: false + other_residential_property_value: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Scottish purchases do not incur SDLT + period: 2025 + input: + region: SCOTLAND + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Welsh purchases do not incur SDLT + period: 2025 + input: + region: WALES + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Temporary first-time-buyer nil band includes its upper boundary + period: 2024 + input: + main_residential_property_purchased: 425000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 0 + +- name: Restored first-time-buyer tax starts above three hundred thousand + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 1 diff --git a/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py index 8817cb7159..5760c24a64 100644 --- a/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py +++ b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py @@ -121,7 +121,7 @@ def test_main_residence_flag_never_creates_an_additional_purchase(households, ye price = out["main_residential_property_purchased"] first_home = np.array( [h["main_residential_property_purchased_is_first_home"] for h in households] - ) & (price < main.first.max) + ) & (price <= main.first.max) main_rates_tax = np.where( first_home, main.first.rate.calc(price), main.subsequent.calc(price) ) diff --git a/policyengine_uk/tests/test_sdlt_purchase_schedules.py b/policyengine_uk/tests/test_sdlt_purchase_schedules.py new file mode 100644 index 0000000000..49b215c0b7 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_purchase_schedules.py @@ -0,0 +1,158 @@ +"""Recent SDLT purchase schedules: raw legal dates versus annual sampling. + +Cash vectors are independent literals from FA 2003 s55/Sch6ZA, the SDLT +(Temporary Relief) Act 2023 s1 and Finance Act 2025 ss50-51. They describe +ordinary unprotected purchases, not contract protections or return rounding. +""" + +from pathlib import Path + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterNode + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + + +PRICES = np.array([125_000, 250_000, 925_000, 1_500_000, 1_600_000]) +OLD_MAIN = [0, 2_500, 36_250, 93_750, 105_750] +TEMPORARY_MAIN = [0, 0, 33_750, 91_250, 103_250] + + +@pytest.fixture(scope="module") +def raw_purchase_parameters(): + # Bypass annual conversion only for raw legal-date scale tests. + return ParameterNode( + "purchase", + directory_path=str( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/stamp_duty/residential/purchase" + ), + ) + + +@pytest.mark.parametrize( + "date,main_cash,additional_cash,thresholds,additional_rates,ftb_nil,ftb_max", + [ + ( + "2022-09-22", + OLD_MAIN, + [3_750, 10_000, 64_000, 138_750, 153_750], + [0, 125_000, 250_000, 925_000, 1_500_000], + [0.03, 0.05, 0.08, 0.13, 0.15], + 300_000, + 500_000, + ), + ( + "2022-09-23", + TEMPORARY_MAIN, + [3_750, 7_500, 61_500, 136_250, 151_250], + [0, 250_000, 925_000, 1_500_000], + [0.03, 0.08, 0.13, 0.15], + 425_000, + 625_000, + ), + ( + "2024-10-30", + TEMPORARY_MAIN, + [3_750, 7_500, 61_500, 136_250, 151_250], + [0, 250_000, 925_000, 1_500_000], + [0.03, 0.08, 0.13, 0.15], + 425_000, + 625_000, + ), + ( + "2024-10-31", + TEMPORARY_MAIN, + [6_250, 12_500, 80_000, 166_250, 183_250], + [0, 250_000, 925_000, 1_500_000], + [0.05, 0.10, 0.15, 0.17], + 425_000, + 625_000, + ), + ( + "2025-03-31", + TEMPORARY_MAIN, + [6_250, 12_500, 80_000, 166_250, 183_250], + [0, 250_000, 925_000, 1_500_000], + [0.05, 0.10, 0.15, 0.17], + 425_000, + 625_000, + ), + ( + "2025-04-01", + OLD_MAIN, + [6_250, 15_000, 82_500, 168_750, 185_750], + [0, 125_000, 250_000, 925_000, 1_500_000], + [0.05, 0.07, 0.10, 0.15, 0.17], + 300_000, + 500_000, + ), + ], +) +def test_raw_purchase_schedules( + raw_purchase_parameters, + date, + main_cash, + additional_cash, + thresholds, + additional_rates, + ftb_nil, + ftb_max, +): + purchase = raw_purchase_parameters(date) + np.testing.assert_allclose( + purchase.main.subsequent.calc(PRICES), main_cash, rtol=0, atol=1e-8 + ) + np.testing.assert_allclose( + purchase.additional.rate.calc(PRICES), + additional_cash, + rtol=0, + atol=1e-8, + ) + # This also proves that null brackets disappear, rather than duplicating + # an active threshold during the temporary period. + assert purchase.main.subsequent.thresholds == thresholds + assert purchase.additional.rate.thresholds == thresholds + assert purchase.additional.rate.rates == additional_rates + assert purchase.main.first.max == ftb_max + np.testing.assert_allclose( + purchase.main.first.rate.calc(np.array([ftb_nil, ftb_nil + 20, ftb_max])), + [0, 1, 10_000], + rtol=0, + atol=1e-8, + ) + + +@pytest.fixture(scope="module") +def annual_system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "year,main300k,additional400k,first500k", + [ + (2022, 5_000, 22_000, 10_000), + (2023, 2_500, 19_500, 3_750), + (2024, 2_500, 19_500, 3_750), + (2025, 5_000, 30_000, 10_000), + (2027, 5_000, 30_000, 10_000), + ], +) +def test_annual_purchase_parameters_sample_april_30( + annual_system, year, main300k, additional400k, first500k +): + purchase = annual_system.parameters(year).gov.hmrc.stamp_duty.residential.purchase + np.testing.assert_allclose( + purchase.main.subsequent.calc(np.array([300_000])), main300k, rtol=0, atol=1e-8 + ) + np.testing.assert_allclose( + purchase.additional.rate.calc(np.array([400_000])), + additional400k, + rtol=0, + atol=1e-8, + ) + np.testing.assert_allclose( + purchase.main.first.rate.calc(np.array([500_000])), first500k, rtol=0, atol=1e-8 + ) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py index 8d12e06ad5..c9f4403b45 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py @@ -17,7 +17,7 @@ def formula(household, period, parameters): price = household("main_residential_property_purchased", period) residential_purchase_qualifies_as_first_buy = household( "main_residential_property_purchased_is_first_home", period - ) & (price < price_limit) + ) & (price <= price_limit) main_residential_purchase_tax = where( residential_purchase_qualifies_as_first_buy, stamp_duty.residential.purchase.main.first.rate.calc(price), From 540f4e429cb5a1f28a56cad5f1b6fd7da00e6928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:17:13 +0100 Subject: [PATCH 063/141] Clarify validation scope of SDLT chart caption --- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index c868176975..f138dfa29e 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -21,7 +21,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Residential purchase schedules\n\nThis model covers recent ordinary individual purchases in England and Northern Ireland. An additional-purchase input represents a transaction already classified as subject to the higher rates; the model does not derive that classification from ownership history.\n\n| Effective date | Standard nil band | First-time-buyer nil band | Inclusive first-time-buyer maximum | Additional-dwelling surcharge |\n| --- | ---: | ---: | ---: | ---: |\n| 23 September 2022–30 October 2024 | £250,000 | £425,000 | £625,000 | 3 percentage points |\n| 31 October 2024–31 March 2025 | £250,000 | £425,000 | £625,000 | 5 percentage points |\n| From 1 April 2025 | £125,000 | £300,000 | £500,000 | 5 percentage points |\n\nAbove the temporary £250,000 standard nil band, marginal rates are 5% up to £925,000, 10% up to £1.5 million and 12% thereafter. From 1 April 2025, the £125,000–£250,000 band returns at 2%; higher standard bands remain the same. First-time buyers pay 5% above their nil band **up to and including** the maximum purchase price. A purchase above that maximum loses first-time-buyer relief on the whole transaction.\n\nFor additional dwellings, the full higher-rates schedule applies when consideration is at least £40,000; below that amount the modeled additional purchase pays zero. During the temporary period its rates are 3/8/13/15%, then 5/10/15/17% from 31 October 2024. From 1 April 2025 the restored £125,000 band gives rates of 5/7/10/15/17%. There is no 7% band during the temporary £250,000 period.\n\nSources: [SDLT (Temporary Relief) Act 2023, section 1](https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted), [Finance Act 2025, sections 50–52](https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted), and [Finance Act 2003, Schedule 6ZA](https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA).\n\n### Annual simulation convention and limits\n\nParameters retain those legal dates, but annual simulations sample **30 April** of each year. Thus 2022 uses the pre-September bands, 2023 and 2024 use the temporary bands with the three-point surcharge, and 2025 uses restored bands with the five-point surcharge. A YEAR purchase input cannot select a transaction day within that year.\n\nThe higher-rate changes protect some contracts entered into before 31 October 2024, with exclusions for specified variations, assignments, options and subsales. Substantial-performance rules can also affect liability. Existing inputs cannot select those contract protections: the schedules above describe ordinary **unprotected** purchases. Earlier 2020/2021 holiday coverage and older history remain incomplete. This purchase update does not certify rental, lease, corporate, nonresident, ownership-classification or return-rounding rules, or estimate population impacts.\n\nThe following chart illustrates the **2023** marginal schedules. Its first-home rate curve does not show the discrete loss of relief above the maximum; the cash example below does.\n" + "## Residential purchase schedules\n\nThis model covers recent ordinary individual purchases in England and Northern Ireland. An additional-purchase input represents a transaction already classified as subject to the higher rates; the model does not derive that classification from ownership history.\n\n| Effective date | Standard nil band | First-time-buyer nil band | Inclusive first-time-buyer maximum | Additional-dwelling surcharge |\n| --- | ---: | ---: | ---: | ---: |\n| 23 September 2022–30 October 2024 | £250,000 | £425,000 | £625,000 | 3 percentage points |\n| 31 October 2024–31 March 2025 | £250,000 | £425,000 | £625,000 | 5 percentage points |\n| From 1 April 2025 | £125,000 | £300,000 | £500,000 | 5 percentage points |\n\nAbove the temporary £250,000 standard nil band, marginal rates are 5% up to £925,000, 10% up to £1.5 million and 12% thereafter. From 1 April 2025, the £125,000–£250,000 band returns at 2%; higher standard bands remain the same. First-time buyers pay 5% above their nil band **up to and including** the maximum purchase price. A purchase above that maximum loses first-time-buyer relief on the whole transaction.\n\nFor additional dwellings, the full higher-rates schedule applies when consideration is at least £40,000; below that amount the modeled additional purchase pays zero. During the temporary period its rates are 3/8/13/15%, then 5/10/15/17% from 31 October 2024. From 1 April 2025 the restored £125,000 band gives rates of 5/7/10/15/17%. There is no 7% band during the temporary £250,000 period.\n\nSources: [SDLT (Temporary Relief) Act 2023, section 1](https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted), [Finance Act 2025, sections 50–52](https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted), and [Finance Act 2003, Schedule 6ZA](https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA).\n\n### Annual simulation convention and limits\n\nParameters retain those legal dates, but annual simulations sample **30 April** of each year. Thus 2022 uses the pre-September bands, 2023 and 2024 use the temporary bands with the three-point surcharge, and 2025 uses restored bands with the five-point surcharge. A YEAR purchase input cannot select a transaction day within that year.\n\nThe higher-rate changes protect some contracts entered into before 31 October 2024, with exclusions for specified variations, assignments, options and subsales. Substantial-performance rules can also affect liability. Existing inputs cannot select those contract protections: the schedules above describe ordinary **unprotected** purchases. Earlier 2020/2021 holiday coverage and older history remain incomplete. This purchase update does not certify rental, lease, corporate, nonresident, ownership-classification or return-rounding rules, or estimate population impacts.\n\nThe purchase curves below illustrate the **2023** marginal purchase schedules. The retained rent and non-residential curves are unchanged model illustrations outside this update's legal validation. The first-home rate curve does not show the discrete loss of relief above the maximum; the cash example below does.\n" ] }, { From 83ac102a6b905eb04ce11384578b7a525ae1994d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:07:26 +0100 Subject: [PATCH 064/141] Fix issues from review: refresh stamp duty example outputs --- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 1116 +++++++++++++++++- 1 file changed, 1112 insertions(+), 4 deletions(-) diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index f138dfa29e..a5b8fc7d46 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -26,13 +26,1111 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { "tags": [ "hide-input" ] }, - "outputs": [], + "outputs": [ + { + "output_type": "stream", + "name": "stderr", + "text": "/Users/mariajuaristi/Documents/Codex/2026-10-15/can-x20/work/issue-2454/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n from .autonotebook import tqdm as notebook_tqdm\n" + }, + { + "output_type": "display_data", + "metadata": {}, + "data": { + "text/html": " \n " + } + }, + { + "output_type": "display_data", + "metadata": {}, + "data": { + "application/vnd.plotly.v1+json": { + "data": [ + { + "hovertemplate": "Label=Residential rent
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "Residential rent", + "line": { + "color": "#636efa", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "Residential rent", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 125000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.01, + 0.01 + ], + "yaxis": "y", + "type": "scatter" + }, + { + "hovertemplate": "Label=2023 residential purchase main (first home)
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase main (first home)", + "line": { + "color": "#EF553B", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "2023 residential purchase main (first home)", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 425000, + 625000, + 925000, + 1500000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.05, + 0.05, + 0.1, + 0.12, + 0.12 + ], + "yaxis": "y", + "type": "scatter" + }, + { + "hovertemplate": "Label=2023 residential purchase main standard
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase main standard", + "line": { + "color": "#00cc96", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "2023 residential purchase main standard", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 250000, + 925000, + 1500000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.05, + 0.1, + 0.12, + 0.12 + ], + "yaxis": "y", + "type": "scatter" + }, + { + "hovertemplate": "Label=2023 residential purchase additional
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase additional", + "line": { + "color": "#ab63fa", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "2023 residential purchase additional", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 40000, + 250000, + 925000, + 1500000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.03, + 0.08, + 0.13, + 0.15, + 0.15 + ], + "yaxis": "y", + "type": "scatter" + }, + { + "hovertemplate": "Label=Non-residential rent
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "Non-residential rent", + "line": { + "color": "#FFA15A", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "Non-residential rent", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 150000, + 5000000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.01, + 0.02, + 0.02 + ], + "yaxis": "y", + "type": "scatter" + }, + { + "hovertemplate": "Label=Non-residential purchase
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "Non-residential purchase", + "line": { + "color": "#19d3f3", + "dash": "solid", + "shape": "hv" + }, + "marker": { + "symbol": "circle" + }, + "mode": "lines", + "name": "Non-residential purchase", + "orientation": "v", + "showlegend": true, + "x": [ + 0, + 150000, + 250000, + 5200000 + ], + "xaxis": "x", + "y": [ + 0.0, + 0.02, + 0.05, + 0.05 + ], + "yaxis": "y", + "type": "scatter" + } + ], + "layout": { + "template": { + "data": { + "barpolar": [ + { + "marker": { + "line": { + "color": "white", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "white", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "#C8D4E3", + "linecolor": "#C8D4E3", + "minorgridcolor": "#C8D4E3", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "#C8D4E3", + "linecolor": "#C8D4E3", + "minorgridcolor": "#C8D4E3", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "heatmapgl": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmapgl" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "white", + "showlakes": true, + "showland": true, + "subunitcolor": "#C8D4E3" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "white", + "polar": { + "angularaxis": { + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "" + }, + "bgcolor": "white", + "radialaxis": { + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + }, + "yaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + }, + "zaxis": { + "backgroundcolor": "white", + "gridcolor": "#DFE8F3", + "gridwidth": 2, + "linecolor": "#EBF0F8", + "showbackground": true, + "ticks": "", + "zerolinecolor": "#EBF0F8" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + }, + "baxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + }, + "bgcolor": "white", + "caxis": { + "gridcolor": "#DFE8F3", + "linecolor": "#A2B1C6", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "#EBF0F8", + "linecolor": "#EBF0F8", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "#EBF0F8", + "zerolinewidth": 2 + } + } + }, + "xaxis": { + "anchor": "y", + "domain": [ + 0.0, + 1.0 + ], + "title": { + "text": "Threshold" + }, + "tickprefix": "£" + }, + "yaxis": { + "anchor": "x", + "domain": [ + 0.0, + 1.0 + ], + "title": { + "text": "Rate" + }, + "tickformat": ",.0%" + }, + "legend": { + "title": { + "text": "" + }, + "tracegroupgap": 0, + "orientation": "h", + "yanchor": "bottom", + "y": -0.3, + "xanchor": "right", + "x": 0.9 + }, + "title": { + "text": "Stamp duty land tax rates over property price thresholds" + }, + "font": { + "family": "Roboto Serif", + "color": "black" + }, + "images": [ + { + "sizex": 0.15, + "sizey": 0.15, + "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", + "x": 1.1, + "xanchor": "right", + "xref": "paper", + "y": -0.15, + "yanchor": "bottom", + "yref": "paper" + } + ], + "height": 600, + "width": 800, + "modebar": { + "bgcolor": "rgba(0,0,0,0)", + "color": "rgba(0,0,0,0)" + } + }, + "config": { + "plotlyServerURL": "https://plot.ly" + } + }, + "text/html": "
    " + } + } + ], "source": [ "import pandas as pd\n", "import plotly.express as px\n", @@ -125,13 +1223,23 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "tags": [ "hide-input" ] }, - "outputs": [], + "outputs": [ + { + "output_type": "execute_result", + "metadata": {}, + "data": { + "text/plain": " Purchase price SDLT\n0 625000 10000.0\n1 625020 18751.0", + "text/html": "
    \n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    Purchase priceSDLT
    062500010000.0
    162502018751.0
    \n
    " + }, + "execution_count": 2 + } + ], "source": [ "from policyengine_uk import Simulation\n", "\n", From 13a4442f96629e28499f9c97b98616713429efa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:25:03 +0100 Subject: [PATCH 065/141] Remove stored local-path warning from SDLT notebook --- docs/book/programs/gov/hmrc/stamp-duty.ipynb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index a5b8fc7d46..ba630963eb 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -33,11 +33,6 @@ ] }, "outputs": [ - { - "output_type": "stream", - "name": "stderr", - "text": "/Users/mariajuaristi/Documents/Codex/2026-10-15/can-x20/work/issue-2454/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n from .autonotebook import tqdm as notebook_tqdm\n" - }, { "output_type": "display_data", "metadata": {}, From 02076029b62d8f42b6c271ce016d3fd4a320f218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:28:08 +0100 Subject: [PATCH 066/141] Expand Savings Credit regression coverage and metadata --- .../2121-savings-credit-followup.changed.md | 1 + .../savings_credit/savings_credit.yaml | 40 +++++ .../tests/test_savings_credit_eligibility.py | 168 +++++++++++++++++- .../savings_credit/savings_credit.py | 17 +- 4 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2121-savings-credit-followup.changed.md diff --git a/changelog.d/2121-savings-credit-followup.changed.md b/changelog.d/2121-savings-credit-followup.changed.md new file mode 100644 index 0000000000..7114380da5 --- /dev/null +++ b/changelog.d/2121-savings-credit-followup.changed.md @@ -0,0 +1 @@ +- Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index f43589f7f5..cab1abeb8d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -25,3 +25,43 @@ minimum_guarantee: 12_376 output: savings_credit: 684.216 + + +# Explicit SI 2019/37 art.4 saving INPUT CONTRACT, not an inference from current +# receipt or proof of the separate 2016 Savings Credit continuity conditions. +# Current 2026 model-rate premises: married couple/default Marriage Allowance +# of 1,260 gives tax (20,000-12,570-1,260)*0.2=1,234 and income 18,766. +# Guarantee: 363.25*52=18,889; threshold: 329.75*52=17,147. +# SC=min(0.6*(18,889-17,147),0.6*(18,766-17,147))=971.40; GC=123. +# This hand control is not a new external audit of 2026 tax or uprating rates. +- name: Protected mixed-age couple keeps the computed Savings Credit component + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + is_claimant_or_partner: true + state_pension: 20_000 + person2: + age: 60 + is_claimant_or_partner: true + benunits: + benunit: + members: [person1, person2] + has_mixed_age_couple_pension_credit_saving: true + would_claim_pc: true + households: + household: + members: [person1, person2] + country: ENGLAND + savings: 0 + output: + income_tax: [1_234, 0] + pension_credit_income: 18_766 + savings_credit_income: 18_766 + standard_minimum_guarantee: 18_889 + savings_credit: 971.40 + guarantee_credit: 123 + pension_credit_entitlement: 1_094.40 + pension_credit: 1_094.40 diff --git a/policyengine_uk/tests/test_savings_credit_eligibility.py b/policyengine_uk/tests/test_savings_credit_eligibility.py index 04a2275d1c..7f3334a696 100644 --- a/policyengine_uk/tests/test_savings_credit_eligibility.py +++ b/policyengine_uk/tests/test_savings_credit_eligibility.py @@ -6,6 +6,10 @@ mixed-age Savings Credit continuity is inferred from reported Pension Credit. """ +from copy import deepcopy + +from hypothesis import example, given, settings +from hypothesis import strategies as st import numpy as np import pytest @@ -55,9 +59,9 @@ def situation(): return {"people": people, "benunits": benunits, "households": households} -def ungated_simulation(): +def ungated_simulation(data=None): """Retain the former amount formula for a downstream differential control.""" - sim = Simulation(situation=situation()) + sim = Simulation(situation=situation() if data is None else data) sim.tax_benefit_system.update_variable( type("savings_credit", (savings_credit,), {"defined_for": None}) ) @@ -150,3 +154,163 @@ def test_direct_savings_credit_input_keeps_its_existing_override_behavior(): assert sim.calculate("pension_credit_entitlement", YEAR)[0] == 0 assert sim.calculate("pension_credit", YEAR)[0] == 0 assert sim.calculate("housing_benefit", YEAR)[0] == 0 + + +# These are baseline input-contract invariants, not a proof of lawful historical +# Savings Credit continuity or population impacts. All components are computed; +# only the documented saving/closure scenario flags are supplied as inputs. +CONSUMER_OUTPUTS = [ + "guarantee_credit", + "pension_credit_entitlement", + "pension_credit", + "in_receipt_of_savings_credit_only", + "housing_benefit_applicable_income", + "housing_benefit_assessable_capital", + "housing_benefit_tariff_income", + "housing_benefit", + "council_tax_reduction_applicable_income", + "council_tax_reduction_assessable_capital", + "council_tax_reduction", + "targeted_childcare_entitlement", +] + + +def _control_unit(*, protected=False, closed=False, claim=False, both_old=False): + return { + "adults": [ + (80, 9_000 if both_old else (0 if closed else 20_000), 0, 0), + (78 if both_old else 60, 9_000 if both_old else 0, 0, 0), + ], + "protected": protected, + "closed": closed, + "claim_at_closure": claim, + "claim_pc": True, + "claim_uc": False, + "rent": 6_240, + "capital": 0, + "expected_left": protected and closed and claim, + } + + +@st.composite +def _unit_cases(draw): + shape = draw(st.sampled_from(["mixed", "both_old", "working"])) + ages = { + "mixed": (draw(st.integers(80, 90)), draw(st.integers(25, 60))), + "both_old": (draw(st.integers(80, 90)), draw(st.integers(80, 90))), + "working": (draw(st.integers(25, 60)), draw(st.integers(25, 60))), + }[shape] + return { + "adults": [ + ( + age, + draw(st.integers(0, 25_000)) if age >= 80 else 0, + draw(st.integers(0, 30_000)), + draw(st.integers(0, 10_000)), + ) + for age in ages + ], + "protected": draw(st.booleans()), + "closed": draw(st.booleans()), + "claim_at_closure": draw(st.booleans()), + "claim_pc": draw(st.booleans()), + "claim_uc": draw(st.booleans()), + "rent": draw(st.integers(0, 12_000)), + "capital": draw(st.integers(0, 20_000)), + } + + +def _generated_situation(units): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + members = [] + for j, (age, pension, earnings, private_pension) in enumerate(unit["adults"]): + name = f"person{i}_{j}" + members.append(name) + people[name] = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: True}, + "state_pension": {YEAR: pension}, + "employment_income": {YEAR: earnings}, + "private_pension_income": {YEAR: private_pension}, + } + benunits[f"b{i}"] = { + "members": members, + "has_mixed_age_couple_pension_credit_saving": {YEAR: unit["protected"]}, + "legacy_benefits_closed": {YEAR: unit["closed"]}, + "would_claim_uc_at_legacy_closure": {YEAR: unit["claim_at_closure"]}, + "would_claim_pc": {YEAR: unit["claim_pc"]}, + "would_claim_uc": {YEAR: unit["claim_uc"]}, + "would_claim_housing_benefit": {YEAR: True}, + } + households[f"h{i}"] = { + "members": members, + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: unit["rent"]}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: unit["capital"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def _assert_computed_component_invariants(sim): + # SPCA 2002 ss.1(2)-(4), 3 and 4(1A): shared entitlement qualifies the + # computed component. An explicit saving flag selects the existing route; + # it does not prove the separate pre-2016 Savings Credit history. + amount = sim.calculate("savings_credit", YEAR) + eligible = sim.calculate("is_pension_credit_eligible", YEAR) + assert np.all((amount <= 0) | eligible), ( + "positive computed SC requires shared eligibility" + ) + np.testing.assert_allclose( + sim.calculate("pension_credit_entitlement", YEAR), + sim.calculate("guarantee_credit", YEAR) + amount, + atol=0.01, + rtol=0, + ) + + +@settings(max_examples=25, derandomize=True, database=None, deadline=None) +@given(units=st.lists(_unit_cases(), min_size=1, max_size=6)) +@example(units=[_control_unit()]) +@example(units=[_control_unit(protected=True)]) +@example(units=[_control_unit(both_old=True)]) +@example(units=[_control_unit(protected=True, closed=True, claim=True)]) +def test_savings_credit_generated_invariants(units): + data = _generated_situation(units) + fixed = Simulation(situation=deepcopy(data)) + former = ungated_simulation(deepcopy(data)) + _assert_computed_component_invariants(fixed) + for i, unit in enumerate(units): + if "expected_left" in unit: + assert ( + bool(fixed.calculate("left_pension_route_at_legacy_closure", YEAR)[i]) + == unit["expected_left"] + ) + # Deliberately exclude raw Savings Credit: its unprotected off-route amount + # is precisely the value the shared gate changes. Consumers keep their gates. + for variable in CONSUMER_OUTPUTS: + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + former.calculate(variable, YEAR), + atol=0.01, + rtol=0, + err_msg=variable, + ) + + +def test_ungated_counterexample_detects_positive_component_without_entitlement(): + data = _generated_situation([_control_unit()]) + fixed = Simulation(situation=deepcopy(data)) + former = ungated_simulation(deepcopy(data)) + _assert_computed_component_invariants(fixed) + assert former.calculate("savings_credit", YEAR)[0] > 0 + assert not former.calculate("is_pension_credit_eligible", YEAR)[0] + # This negative control proves the invariant would detect losing the gate. + # Current main already has the gate; this is not a claimed production RED. + with pytest.raises( + AssertionError, match="positive computed SC requires shared eligibility" + ): + _assert_computed_component_invariants(former) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py index 62b6eb3e2c..13c0c4b645 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py @@ -7,12 +7,19 @@ class savings_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/3", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", + ) documentation = ( - "The savings credit element of State Pension Credit, zero unless the " - "benefit unit meets the shared Pension Credit entitlement conditions. " - "This is the computed component before Pension Credit take-up; a " - "positive amount does not by itself establish an award in payment." + "The computed savings credit element of State Pension Credit, zero unless " + "the benefit unit meets the shared Pension Credit entitlement conditions. " + "An explicitly supplied input overrides the computed component. The " + "component is calculated before Pension Credit take-up, which applies " + "later to total Pension Credit entitlement; a positive component does " + "not by itself establish an award in payment." ) defined_for = "is_pension_credit_eligible" From f81447abe7f0b71f1c76580488949110646da765 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 16:25:23 -0400 Subject: [PATCH 067/141] Count IIDB, SDA and incapacity benefit in Council Tax Reduction income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scheme council_tax_reduction_applicable_income serves counts them: SI 2012/2885 Sch 1 para 16(1)(j), SI 2012/2886 Sch paras 39(1)(j) and 54(1), WSI 2013/3029 Sch 1 para 10(1)(j) and Sch 6 para 17(1), SSI 2012/319 reg 27(1)(j) and SSI 2021/249 reg 57(1)(b)(ix)-(xi). War pensions and AFCS payments stay uncounted: Scotland disregards or omits them, and English and Welsh councils may disregard beyond the prescribed £10 a week (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11), s.13A(1)(c)). Part of #2510. Co-Authored-By: Claude Opus 5.5 --- .../ctr-income-prescribed-benefits.fixed.md | 1 + ...council_tax_reduction_applicable_income.py | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 changelog.d/ctr-income-prescribed-benefits.fixed.md diff --git a/changelog.d/ctr-income-prescribed-benefits.fixed.md b/changelog.d/ctr-income-prescribed-benefits.fixed.md new file mode 100644 index 0000000000..6fbc0df1bb --- /dev/null +++ b/changelog.d/ctr-income-prescribed-benefits.fixed.md @@ -0,0 +1 @@ +Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 5f09f4c4a8..66ef496f32 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -17,7 +17,12 @@ class council_tax_reduction_applicable_income(Variable): "also exclude Child Benefit from general-rule income; statutory sick " "and maternity pay remain counted as earnings. The other adjustments those " "provisions allow (childcare charges, the £20 earnings disregards, " - "maintenance disregards and the rest) are not modelled." + "maintenance disregards and the rest) are not modelled. Industrial " + "injuries disablement benefit, severe disablement allowance and " + "incapacity benefit count in every scheme. War pensions and Armed " + "Forces Compensation Scheme payments do not: Scotland disregards or " + "omits them, and English and Welsh councils may disregard them beyond " + "the prescribed £10 a week." ) definition_period = YEAR unit = GBP @@ -35,6 +40,15 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/1", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/39", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/54", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/34", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/17", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/1", + "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", ] def formula(benunit, period, parameters): @@ -94,7 +108,39 @@ def formula(benunit, period, parameters): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", + # Every scheme the model runs counts these three benefits. At + # pension age they are social security benefits that the income + # lists do not except: SI 2012/2885 Sch 1 para 16(1)(j), the + # default scheme SI 2012/2886 Sch para 39(1)(j), WSI 2013/3029 + # Sch 1 para 10(1)(j) and SSI 2012/319 reg 27(1)(j). At working + # age England and Wales count gross income (SI 2012/2886 Sch para + # 54(1); WSI 2013/3029 Sch 6 para 17(1)) less disregards that do + # not list them (their Schs 8 and 9), and Scotland's closed list + # names them (SSI 2021/249 reg 57(1)(b)(ix) to (xi)). Each scheme + # leaves out IIDB's constant attendance and exceptionally severe + # disablement increases (SSCBA 1992 ss.104 and 105), by name or as + # "attendance allowance" (Sch 8 and Sch 9 para 14), but iidb is + # the whole reported payment and cannot separate them. + "iidb", + "sda", + "incapacity_benefit", ] + # War disablement pensions, war widow's or widower's pensions and + # Armed Forces Compensation Scheme payments (afcs, war_widows_pension) + # are not counted. Scotland counts none of them: SSI 2012/319 Sch 3 + # para 1 disregards each in full, and SSI 2021/249 reg 57(1), a closed + # list, does not name them. England and Wales prescribe a £10 a week + # disregard (SI 2012/2885 Sch 5 para 1; SI 2012/2886 Sch 6 para 1 and + # Sch 8 para 20; WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20), which + # a council may exceed: Wales makes it a minimum for war disablement + # and war widow's or widower's pensions (WSI 2013/3029 reg 34(5)), an + # English scheme may make provision equivalent to the council tax + # benefit rules (LGFA 1992 Sch 1A para 2(11)), and any council may + # reduce council tax further (s.13A(1)(c)). The model takes the full + # disregard, as Housing Benefit does for local schemes under SSAA 1992 + # s.134(8)(a). Reg 34(5) does not cover guaranteed income payments, so + # in Wales this over-disregards them, but afcs does not separate them + # from war disablement pensions. income_components = [ "employment_income", "self_employment_income", From 8fc098c160491a98aaeb0742ee90b7a436b3e904 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:32:31 -0400 Subject: [PATCH 068/141] =?UTF-8?q?Count=20war=20pensions=20less=20=C2=A31?= =?UTF-8?q?0=20a=20week=20under=20the=20Kingston=20and=20Newham=20schemes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kingston upon Thames (Sch 8 para 20, capped at £20 a week by para 40) and Newham (Sch 8 para 19) disregard only £10 a week of each war disablement pension, war widow's pension and AFCS guaranteed income payment for working-age applicants. Westminster, Oxford and Merton disregard them in full, and the national schemes keep the full disregard. The per-payment reading follows Pension Credit's identically worded Sch IV para 1. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- .../ctr-income-prescribed-benefits.fixed.md | 2 +- .../means_test/war_pension_disregard.yaml | 10 +++ .../war_pension_disregard_limit.yaml | 10 +++ .../means_test/war_pension_disregard.yaml | 10 +++ ...council_tax_reduction_applicable_income.py | 88 ++++++++++++++----- 5 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml diff --git a/changelog.d/ctr-income-prescribed-benefits.fixed.md b/changelog.d/ctr-income-prescribed-benefits.fixed.md index 6fbc0df1bb..8a8df3b7b2 100644 --- a/changelog.d/ctr-income-prescribed-benefits.fixed.md +++ b/changelog.d/ctr-income-prescribed-benefits.fixed.md @@ -1 +1 @@ -Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). +Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57), and counts war pensions and Armed Forces Compensation Scheme payments less £10 a week each under the Kingston upon Thames and Newham working-age schemes. diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml new file mode 100644 index 0000000000..08a5de490a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml @@ -0,0 +1,10 @@ +description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from a working-age applicant's income under the Kingston upon Thames Council Tax Reduction scheme. +values: + 2026-04-01: 10 +metadata: + unit: currency-GBP + period: week + label: Kingston upon Thames Council Tax Reduction war pension disregard + reference: + - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 20 (PDF page 136) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136 diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml new file mode 100644 index 0000000000..2a50f0ff9f --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml @@ -0,0 +1,10 @@ +description: Weekly limit on a working-age family's total disregards under Sch 8 paras 20 and 21 (war pensions and widowed parent's allowance) and the student income disregards, under the Kingston upon Thames Council Tax Reduction scheme. +values: + 2026-04-01: 20 +metadata: + unit: currency-GBP + period: week + label: Kingston upon Thames Council Tax Reduction war pension disregard limit + reference: + - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 40 (PDF page 139) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=139 diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml new file mode 100644 index 0000000000..48e1dd254e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml @@ -0,0 +1,10 @@ +description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from a working-age applicant's income under the Newham Council Tax Reduction scheme. +values: + 2026-04-01: 10 +metadata: + unit: currency-GBP + period: week + label: Newham Council Tax Reduction war pension disregard + reference: + - title: Council Tax Reduction Scheme 2026, Sch 8 para 19 (PDF page 292) + href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 66ef496f32..b37764428a 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_kingston_upon_thames_working_age, + is_newham_working_age, +) from policyengine_uk.variables.household.demographic.country import Country @@ -20,9 +24,11 @@ class council_tax_reduction_applicable_income(Variable): "maintenance disregards and the rest) are not modelled. Industrial " "injuries disablement benefit, severe disablement allowance and " "incapacity benefit count in every scheme. War pensions and Armed " - "Forces Compensation Scheme payments do not: Scotland disregards or " - "omits them, and English and Welsh councils may disregard them beyond " - "the prescribed £10 a week." + "Forces Compensation Scheme payments count, less £10 a week each, " + "only under the Kingston upon Thames and Newham working-age schemes: " + "Scotland disregards or omits them, and elsewhere in England and " + "Wales councils may disregard them beyond the prescribed £10 a week, " + "so the model takes the full disregard." ) definition_period = YEAR unit = GBP @@ -40,15 +46,15 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", - "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/1", "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/39", "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/54", "https://www.legislation.gov.uk/wsi/2013/3029/regulation/34", - "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/17", "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/1", "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", + "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136", + "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292", ] def formula(benunit, period, parameters): @@ -126,21 +132,28 @@ def formula(benunit, period, parameters): "incapacity_benefit", ] # War disablement pensions, war widow's or widower's pensions and - # Armed Forces Compensation Scheme payments (afcs, war_widows_pension) - # are not counted. Scotland counts none of them: SSI 2012/319 Sch 3 - # para 1 disregards each in full, and SSI 2021/249 reg 57(1), a closed - # list, does not name them. England and Wales prescribe a £10 a week - # disregard (SI 2012/2885 Sch 5 para 1; SI 2012/2886 Sch 6 para 1 and - # Sch 8 para 20; WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20), which - # a council may exceed: Wales makes it a minimum for war disablement - # and war widow's or widower's pensions (WSI 2013/3029 reg 34(5)), an - # English scheme may make provision equivalent to the council tax - # benefit rules (LGFA 1992 Sch 1A para 2(11)), and any council may - # reduce council tax further (s.13A(1)(c)). The model takes the full - # disregard, as Housing Benefit does for local schemes under SSAA 1992 - # s.134(8)(a). Reg 34(5) does not cover guaranteed income payments, so - # in Wales this over-disregards them, but afcs does not separate them - # from war disablement pensions. + # Armed Forces Compensation Scheme payments (afcs, war_widows_pension). + # Scotland counts none of them: SSI 2012/319 Sch 3 para 1 disregards + # each in full, and SSI 2021/249 reg 57(1), a closed list, does not + # name them. England and Wales prescribe a £10 a week disregard (SI + # 2012/2885 Sch 5 para 1; SI 2012/2886 Sch 6 para 1 and Sch 8 para 20; + # WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20), which a council may + # exceed: Wales makes it a minimum for war disablement and war widow's + # or widower's pensions (WSI 2013/3029 reg 34(5)), an English scheme + # may make provision equivalent to the council tax benefit rules (LGFA + # 1992 Sch 1A para 2(11)), and any council may reduce council tax + # further (s.13A(1)(c)). Where the model cannot see the council's + # choice it takes the full disregard, as Housing Benefit does for local + # schemes under SSAA 1992 s.134(8)(a). Reg 34(5) does not cover + # guaranteed income payments, so in Wales that over-disregards them, + # but afcs does not separate them from war disablement pensions. + # Of the English working-age schemes the model runs, Westminster's, + # Oxford's and Merton's disregard war pensions in full. Kingston upon + # Thames (Sch 8 para 20) and Newham (Sch 8 para 19) disregard £10 a + # week of each payment, the reading Pension Credit's identically + # worded disregard takes, and Kingston limits the family's total to + # £20 a week (Sch 8 para 40). Newham's limit (its para 39) names its + # paras 20 and 21, not the war pension para 19, so it does not apply. income_components = [ "employment_income", "self_employment_income", @@ -174,6 +187,41 @@ def formula(benunit, period, parameters): credits = add_for_members(benunit, period, ["tax_credits"], members) increased_income = income + personal_benefit_income + credits + benefits + local_authority = benunit.household("local_authority", period) + kingston = is_kingston_upon_thames_working_age( + local_authority, country, pensioner + ) + newham = is_newham_working_age(local_authority, country, pensioner) + local_authorities = parameters(period).gov.local_authorities + kingston_test = ( + local_authorities.kingston_upon_thames.council_tax_reduction.means_test + ) + newham_test = local_authorities.newham.council_tax_reduction.means_test + payment_disregard = WEEKS_IN_YEAR * where( + kingston, + kingston_test.war_pension_disregard, + newham_test.war_pension_disregard, + ) + war_pensions = 0 + war_pension_disregards = 0 + for payment in ["afcs", "war_widows_pension"]: + amount = person(payment, period) * members + war_pensions += benunit.sum(amount) + war_pension_disregards += benunit.sum( + min_(amount, benunit.project(payment_disregard)) + ) + war_pension_disregards = where( + kingston, + min_( + war_pension_disregards, + kingston_test.war_pension_disregard_limit * WEEKS_IN_YEAR, + ), + war_pension_disregards, + ) + increased_income += where( + kingston | newham, war_pensions - war_pension_disregards, 0 + ) + if not bi.interactions.include_in_means_tests: increased_income -= add_for_members( benunit, period, ["basic_income"], members From b26748bd63528073454e7d1015b5e5f90e5aad8e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:57:55 -0400 Subject: [PATCH 069/141] Test IIDB, SDA, IB and war pensions in Council Tax Reduction income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAML cases per scheme at the 2026-27 statutory rates (SI 2026/148), and Hypothesis properties: Council Tax Reduction and Pension Credit assess the same pension-age income; each benefit counts pound for pound in every scheme; war pensions count only under the Kingston upon Thames and Newham working-age schemes, less £10 a week each. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- ...cil_tax_reduction_prescribed_benefits.yaml | 540 ++++++++++++++++++ ...eduction_prescribed_benefits_properties.py | 418 ++++++++++++++ 2 files changed, 958 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml new file mode 100644 index 0000000000..cdb1658fb6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -0,0 +1,540 @@ +# Industrial injuries disablement benefit (IIDB), severe disablement allowance +# (SDA), incapacity benefit (IB) and war pensions in Council Tax Reduction +# income. +# +# Every scheme counts IIDB, SDA and IB: +# - England, pensioners: SI 2012/2885 Sch 1 para 16(1)(j), social security +# benefits other than those listed; (j)(v) excepts only IIDB's increases +# under SSCBA 1992 ss.104 and 105. +# - English working-age schemes: gross income (SI 2012/2886 Sch para 54(1)) +# less Sch 8, which disregards none of the three (para 14 disregards +# "attendance allowance", defined to include the ss.104 and 105 increases). +# Kingston upon Thames, Merton and Newham copy the default scheme; Oxford's +# para 24 counts income other than earnings unless its Appendix 5 lists it. +# - Wales: WSI 2013/3029 Sch 1 para 10(1)(j) and Sch 6 para 17(1) with Sch 9. +# - Scotland: SSI 2012/319 reg 27(1)(j) (pension age) and SSI 2021/249 reg +# 57(1)(b)(ix) to (xi) (working age, a closed list). +# +# War pensions and AFCS payments (afcs, war_widows_pension): +# - Scotland counts none of them: SSI 2012/319 reg 31(10) and Sch 3 para 1 +# disregard each payment in full, and SSI 2021/249 reg 57(1) does not name +# them. +# - England and Wales prescribe a £10 a week disregard (SI 2012/2885 Sch 5 +# para 1; WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20) that councils may +# exceed (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and +# s.13A(1)(c)). The model takes the full disregard where it cannot see the +# council's choice. +# - Of the English working-age schemes the model runs, Westminster's, Oxford's +# and Merton's disregard war pensions in full. Kingston upon Thames (Sch 8 +# para 20) and Newham (Sch 8 para 19) disregard £10 a week of each payment; +# Kingston limits the family's total to £20 a week (Sch 8 para 40). +# +# Weekly rates, 2026-27 (SI 2026/148 Sch 1, restating SSCBA 1992 Sch 4): +# disablement pension at 100% 233.90, 50% 116.95, 20% 46.78; long-term +# incapacity benefit 146.60; severe disablement allowance 103.85 plus the +# higher age related addition 15.50, 119.35 in all. Full new State Pension +# 241.30 (SI 2026/148 art 6(1)), 12,547.60 a year, below the 12,570 personal +# allowance. IIDB, SDA and war pensions are not taxed in the model; State +# Pension and IB are. Pension Credit standard minimum guarantee 238.00 +# (single). CTR applicable amounts: personal allowance 256.00 (single, pension +# age) and 95.55 (single, 25 or over); the reduction is withdrawn at 20% of +# income above the applicable amount. Council tax is 2,000 throughout. + +- name: IIDB counts as pension-age Council Tax Reduction income in England + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 241.30 * 52 + iidb_reported: 116.95 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: true + income_tax: 0 + # 12,547.60 + 6,081.40 + council_tax_reduction_applicable_income: 241.30 * 52 + 116.95 * 52 + council_tax_reduction_applicable_amount: 256.00 * 52 + # 2,000 - 0.2 x (18,629.00 - 13,312.00) = 936.60 + council_tax_benefit: 2_000 - 0.2 * ((241.30 + 116.95 - 256.00) * 52) + +- name: IIDB counts as pension-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 241.30 * 52 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + # 12,547.60 + 12,162.80 + council_tax_reduction_applicable_income: 241.30 * 52 + 233.90 * 52 + +- name: IIDB counts as pension-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 241.30 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + # 12,547.60 + 2,432.56 + council_tax_reduction_applicable_income: 241.30 * 52 + 46.78 * 52 + council_tax_reduction_applicable_amount: 256.00 * 52 + # 2,000 - 0.2 x (14,980.16 - 13,312.00) = 1,666.37 + council_tax_benefit: 2_000 - 0.2 * ((241.30 + 46.78 - 256.00) * 52) + +- name: SDA counts as working-age Council Tax Reduction income under the Westminster scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + sda_reported: 1 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: false + # 103.85 + 15.50 + sda: 119.35 * 52 + council_tax_reduction_applicable_income: 119.35 * 52 + +- name: IB counts as working-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + # 7,623.20 is below the personal allowance. + income_tax: 0 + council_tax_reduction_applicable_income: 146.60 * 52 + +- name: IIDB and IB count as working-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + iidb_reported: 46.78 * 52 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: false + income_tax: 0 + # 2,432.56 + 7,623.20 + council_tax_reduction_applicable_income: (46.78 + 146.60) * 52 + council_tax_reduction_applicable_amount: 95.55 * 52 + # 2,000 - 0.2 x (10,055.76 - 4,968.60) = 982.57 + council_tax_benefit: 2_000 - 0.2 * ((46.78 + 146.60 - 95.55) * 52) + +- name: Taxable IB counts net of income tax under the Westminster scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + incapacity_benefit_reported: 146.60 * 52 + private_pension_income: 10_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + # 0.2 x (7,623.20 + 10,000 - 12,570) = 1,010.64 + income_tax: 0.2 * (146.60 * 52 + 10_000 - 12_570) + # 17,623.20 - 1,010.64 = 16,612.56 + council_tax_reduction_applicable_income: 146.60 * 52 + 10_000 - 0.2 * (146.60 * 52 + 10_000 - 12_570) + +- name: War pensions do not count as pension-age Council Tax Reduction income in England + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + state_pension: 241.30 * 52 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + council_tax_reduction_applicable_income: 241.30 * 52 + +- name: War pensions do not count as working-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + afcs_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: War pensions do not count as pension-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 80 + state_pension: 241.30 * 52 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 241.30 * 52 + +- name: War pensions do not count as working-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + afcs_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: The Kingston upon Thames working-age scheme counts a war pension less 10 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + # 5,000 - 520 + council_tax_reduction_applicable_income: 5_000 - 10 * 52 + +- name: The Kingston upon Thames scheme disregards 10 a week of each war pension + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + # 8,000 - 2 x 520: two payments, two disregards, within the 20 a week limit. + council_tax_reduction_applicable_income: 8_000 - 2 * 10 * 52 + +- name: The Kingston upon Thames scheme limits a family's war pension disregards to 20 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + partner: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + # Four payments would give 4 x 10 a week; Sch 8 para 40 limits the + # family's total to 20 a week: 16,000 - 1,040. + council_tax_reduction_applicable_income: 16_000 - 20 * 52 + +- name: The Newham working-age scheme disregards 10 a week of each war pension, with no family limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + partner: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + country: ENGLAND + region: LONDON + local_authority: NEWHAM + council_tax: 2_000 + savings: 0 + output: + # Newham's limit (its Sch 8 para 39) names paras 20 and 21, not the war + # pension para 19: 16,000 - 4 x 520. + council_tax_reduction_applicable_income: 16_000 - 4 * 10 * 52 + +- name: A war pension below 10 a week counts as nothing under the Newham scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 300 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: NEWHAM + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: The Westminster working-age scheme disregards war pensions in full + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: The national pensioner scheme takes the full war pension disregard in Kingston upon Thames + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + afcs_reported: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 0 + +- name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 150 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: true + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # 7,800 + 2,432.56 = 10,232.56, or 196.78 a week, below the 238.00 + # minimum guarantee (SPC Regs 2002 reg 15(1) counts IIDB). + pension_credit_income: (150 + 46.78) * 52 + in_receipt_of_guarantee_credit: true + # SI 2012/2885 Sch 1 para 13: the whole income is disregarded. + council_tax_reduction_applicable_income: 0 + council_tax_benefit: 2_000 diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py new file mode 100644 index 0000000000..31ea275684 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -0,0 +1,418 @@ +"""IIDB, SDA, incapacity benefit and war pensions in Council Tax Reduction income. + +Council Tax Reduction income left out industrial injuries disablement benefit +(IIDB), severe disablement allowance (SDA) and incapacity benefit (IB), which +every scheme the model runs counts: + +- England, pensioners: SI 2012/2885 Sch 1 para 16(1)(j), social security + benefits other than those it lists; (j)(v) excepts only IIDB's increases + under SSCBA 1992 ss.104 and 105. +- English working-age schemes: gross income (SI 2012/2886 Sch para 54(1)) + less Sch 8, which disregards none of the three; Kingston upon Thames, Merton + and Newham copy it, and Oxford counts income other than earnings unless its + Appendix 5 lists it. +- Wales: WSI 2013/3029 Sch 1 para 10(1)(j) and Sch 6 para 17(1) with Sch 9. +- Scotland: SSI 2012/319 reg 27(1)(j) and SSI 2021/249 reg 57(1)(b)(ix)-(xi). + +Pension Credit counts them too (SPC Regs 2002 reg 15(1)), so once the +guarantee credit passport ends, Council Tax Reduction was assessed on less +income than Pension Credit and Housing Benefit. + +War pensions and Armed Forces Compensation Scheme payments stay uncounted +where the model cannot see the council's choice: Scotland disregards or omits +them (SSI 2012/319 Sch 3 para 1; SSI 2021/249 reg 57(1)), and English and +Welsh councils may disregard more than the prescribed £10 a week (WSI 2013/3029 +reg 34(5); LGFA 1992 Sch 1A para 2(11) and s.13A(1)(c)). Kingston upon Thames +(Sch 8 para 20) and Newham (Sch 8 para 19) disregard only £10 a week of each +payment at working age, and Kingston limits the family's total to £20 a week +(Sch 8 para 40). Westminster, Merton and Oxford disregard them in full. + +Invariants, in 2026, for single people and couples with no children: + +1. Differential: Council Tax Reduction and Pension Credit assess the same + income. For pension-age families under the national pensioner schemes in + England, Wales and Scotland, with State Pension, private pension, IIDB, IB + and SDA, no earnings, no pension contributions and savings of at most + £10,000 (so neither scheme's deemed income from capital applies): + Council Tax Reduction income is nil where guarantee credit is received + (SI 2012/2885 Sch 1 para 13 and equivalents), Pension Credit income plus + the savings credit where the award is savings credit only (para 14), and + Pension Credit income otherwise. +2. Pound for pound, every scheme: raising IIDB or IB by d, or awarding SDA, + raises Council Tax Reduction income plus the family's income tax and + National Insurance by exactly the added amount, wherever the general rules + apply before and after and the zero floor does not bind. The reduction + never rises where the route (general rules, savings credit only, guarantee + credit) is unchanged; in the national schemes, where both awards are + strictly between nil and the liability, it falls by 20% of the rise in + income. +3. War pensions: adding war pensions leaves Council Tax Reduction income + unchanged under the general rules, except under the Kingston upon Thames + and Newham working-age schemes, where it rises by the payments less £10 a + week of each (in Kingston, less at most £20 a week in all). The disregards + are written out here from the schemes, not read from the parameters. + +These compare the model's own measures. Invariant 1 relies on both measures +deducting the family's whole income tax and National Insurance, an existing +approximation in each. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +# 2026-27 weekly rates (SI 2026/148 Sch 1). +IIDB_FULL = 233.90 * WEEKS +LONG_TERM_IB = 146.60 * WEEKS +SDA = (103.85 + 15.50) * WEEKS +WITHDRAWAL_RATE = 0.2 +# £10 a week of each war pension; Kingston's £20 a week family limit. +LOCAL_WAR_PENSION_DISREGARD = 10 * WEEKS +KINGSTON_DISREGARD_LIMIT = 20 * WEEKS +PRIVATE_PENSIONS = np.arange(0, 20_001, 1_000) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Areas of the national pensioner schemes, and of the working-age schemes. +PENSION_AGE_AREAS = { + "MAIDSTONE": ("ENGLAND", "SOUTH_EAST"), + "KINGSTON_UPON_THAMES": ("ENGLAND", "LONDON"), + "CARDIFF": ("WALES", "WALES"), + "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), +} +WORKING_AGE_AREAS = { + "WESTMINSTER": ("ENGLAND", "LONDON"), + "MERTON": ("ENGLAND", "LONDON"), + "KINGSTON_UPON_THAMES": ("ENGLAND", "LONDON"), + "NEWHAM": ("ENGLAND", "LONDON"), + "OXFORD": ("ENGLAND", "SOUTH_EAST"), + "CARDIFF": ("WALES", "WALES"), + "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), +} +NATIONAL_SCHEME_AREAS = {"MAIDSTONE", "CARDIFF", "GLASGOW_CITY"} +LOCAL_WAR_PENSION_AREAS = {"KINGSTON_UPON_THAMES", "NEWHAM"} +BENUNIT_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_relevant_income_based_benefit", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + "pension_credit_income", + "pension_credit", +] +PERSON_TOTALS = ["income_tax", "national_insurance"] + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def benefits(draw): + """IIDB at a 20% to 100% assessment, IB up to the long-term rate, SDA.""" + return dict( + iidb=draw(st.one_of(st.just(0.0), money(IIDB_FULL))), + incapacity_benefit=draw(st.one_of(st.just(0.0), money(LONG_TERM_IB))), + sda=draw(st.booleans()), + ) + + +@st.composite +def pension_age_family(draw, areas=tuple(PENSION_AGE_AREAS)): + adults = draw(st.integers(1, 2)) + return dict( + ages=[draw(st.integers(67, 95)) for _ in range(adults)], + state_pensions=[draw(money(15_000)) for _ in range(adults)], + earnings=[0.0] * adults, + benefits=[draw(benefits()) for _ in range(adults)], + property_income=draw(st.one_of(st.just(0.0), money(5_000))), + area=draw(st.sampled_from(areas)), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + would_claim_pc=draw(st.booleans()), + ) + + +@st.composite +def working_age_family(draw): + adults = draw(st.integers(1, 2)) + return dict( + ages=[draw(st.integers(25, 60)) for _ in range(adults)], + state_pensions=[0.0] * adults, + earnings=[draw(st.one_of(st.just(0.0), money(30_000))) for _ in range(adults)], + benefits=[draw(benefits()) for _ in range(adults)], + property_income=draw(st.one_of(st.just(0.0), money(5_000))), + area=draw(st.sampled_from(tuple(WORKING_AGE_AREAS))), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), money(5_000))), + would_claim_pc=False, + ) + + +any_family = st.one_of(pension_age_family(), working_age_family()) + +# A single pensioner off Pension Credit with every benefit and State Pension +# below the personal allowance, so the general rules apply in every cell. +GENERAL_RULES_PENSIONER = dict( + ages=[75], + state_pensions=[9_000.0], + earnings=[0.0], + benefits=[dict(iidb=IIDB_FULL * 0.5, incapacity_benefit=LONG_TERM_IB, sda=True)], + property_income=0.0, + area="MAIDSTONE", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) +# A pensioner whose reduction is partial before and after each change, so the +# 20% taper in invariant 2 is checked. +PARTIAL_AWARD_PENSIONER = dict( + ages=[70], + state_pensions=[241.30 * WEEKS], + earnings=[0.0], + benefits=[dict(iidb=0.0, incapacity_benefit=0.0, sda=False)], + property_income=0.0, + area="MAIDSTONE", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) +# Two adults on the Kingston upon Thames working-age scheme, so the £20 limit +# binds once each has two war pensions. +KINGSTON_COUPLE = dict( + ages=[40, 40], + state_pensions=[0.0, 0.0], + earnings=[8_000.0, 0.0], + benefits=[dict(iidb=IIDB_FULL * 0.3, incapacity_benefit=0.0, sda=False)] * 2, + property_income=0.0, + area="KINGSTON_UPON_THAMES", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) + + +def area_of(fam): + if fam["ages"][0] >= 67: + return PENSION_AGE_AREAS[fam["area"]] + return WORKING_AGE_AREAS[fam["area"]] + + +def situation(variants): + """One benefit unit and household per (family, private pension, change). + + variants: list of (family, private pension of the first adult, extra + person inputs for the first adult). + """ + people, benunits, households = {}, {}, {} + for i, (fam, private_pension, extra) in enumerate(variants): + country, region = area_of(fam) + names = [] + for j, age in enumerate(fam["ages"]): + name = f"p{i}_{j}" + b = fam["benefits"][j] + person = { + "age": {YEAR: age}, + "state_pension": {YEAR: fam["state_pensions"][j]}, + "employment_income": {YEAR: fam["earnings"][j]}, + "private_pension_income": {YEAR: private_pension * (j == 0)}, + "property_income": {YEAR: fam["property_income"] * (j == 0)}, + "iidb_reported": {YEAR: b["iidb"]}, + "incapacity_benefit_reported": {YEAR: b["incapacity_benefit"]}, + "sda_reported": {YEAR: float(b["sda"])}, + } + for variable, value in extra.get(j, {}).items(): + person[variable] = {YEAR: value} + people[name] = person + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {YEAR: fam["would_claim_pc"]}, + "would_claim_uc": {YEAR: False}, + } + households[f"h{i}"] = { + "members": names, + "country": {YEAR: country}, + "region": {YEAR: region}, + "local_authority": {YEAR: fam["area"]}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(variants): + simulation = Simulation(situation=situation(variants)) + values = {v: np.asarray(simulation.calculate(v, YEAR)) for v in BENUNIT_VARIABLES} + values["tax"] = sum( + np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) + for v in PERSON_TOTALS + ) + values["liability"] = np.asarray( + simulation.calculate("council_tax_reduction_maximum_eligible_liability", YEAR) + ) + return values + + +def route(values): + """0: general rules; 1: savings credit only; 2: guarantee credit.""" + gc = values["in_receipt_of_guarantee_credit"].astype(bool) + sc_only = values["in_receipt_of_savings_credit_only"].astype(bool) + return np.where(gc, 2, np.where(sc_only, 1, 0)) + + +@PROPERTY_SETTINGS +@given(st.lists(pension_age_family(), min_size=1, max_size=4)) +@example([GENERAL_RULES_PENSIONER]) +def test_council_tax_reduction_and_pension_credit_assess_the_same_income( + families, +): + variants = [(fam, p, {}) for fam in families for p in PRIVATE_PENSIONS] + v = calculate(variants) + pensioner = v["council_tax_reduction_pensioner"].astype(bool) + assert np.all(pensioner) + ctr_income = v["council_tax_reduction_applicable_income"] + pc_income = v["pension_credit_income"] + r = route(v) + expected = np.select( + [r == 2, r == 1], [0.0, pc_income + v["pension_credit"]], default=pc_income + ) + assert np.allclose(ctr_income, expected, atol=0.01), [ + (variants[k][0], variants[k][1], ctr_income[k], expected[k], r[k]) + for k in np.flatnonzero(~np.isclose(ctr_income, expected, atol=0.01))[:3] + ] + if families == [GENERAL_RULES_PENSIONER]: + # Not vacuous: the general rules apply and count every benefit. + assert np.all(r == 0) + b = GENERAL_RULES_PENSIONER["benefits"][0] + counted = 9_000 + b["iidb"] + b["incapacity_benefit"] + SDA + assert np.isclose(ctr_income[0], counted - v["tax"][0], atol=0.01) + + +CHANGES = { + "iidb": lambda b: {"iidb_reported": b["iidb"] + 0.2 * IIDB_FULL}, + "incapacity_benefit": lambda b: { + "incapacity_benefit_reported": b["incapacity_benefit"] + 0.3 * LONG_TERM_IB + }, + "sda": lambda b: {"sda_reported": 1.0}, +} +ADDED = { + "iidb": lambda b: 0.2 * IIDB_FULL, + "incapacity_benefit": lambda b: 0.3 * LONG_TERM_IB, + "sda": lambda b: 0.0 if b["sda"] else SDA, +} + + +@PROPERTY_SETTINGS +@given( + st.lists(any_family, min_size=1, max_size=6), + st.sampled_from(sorted(CHANGES)), +) +@example( + [GENERAL_RULES_PENSIONER, KINGSTON_COUPLE, PARTIAL_AWARD_PENSIONER], + "incapacity_benefit", +) +@example([PARTIAL_AWARD_PENSIONER], "iidb") +def test_each_benefit_counts_pound_for_pound_in_every_scheme(families, change): + variants = [] + for fam in families: + b = fam["benefits"][0] + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, {0: CHANGES[change](b)})) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + added = np.array([ADDED[change](fam["benefits"][0]) for fam in families]) + income = v["council_tax_reduction_applicable_income"] + with_tax = income + v["tax"] + r = route(v) + passported = v["council_tax_reduction_relevant_income_based_benefit"].astype(bool) + general = (r[base] == 0) & (r[plus] == 0) & ~passported[base] & ~passported[plus] + unfloored = general & (income[base] > 0) + assert np.allclose( + (with_tax[plus] - with_tax[base])[unfloored], added[unfloored], atol=0.01 + ), (change, [f for f, u in zip(families, unfloored) if u]) + ctr = v["council_tax_benefit"] + same_route = r[base] == r[plus] + assert np.all((ctr[plus] <= ctr[base] + 0.01)[same_route]), change + national = np.array([fam["area"] in NATIONAL_SCHEME_AREAS for fam in families]) + liability = v["liability"] + partial = ( + general + & national + & (ctr[base] > 0.01) + & (ctr[plus] > 0.01) + & (ctr[base] < liability[base] - 0.01) + & (ctr[plus] < liability[plus] - 0.01) + ) + assert np.allclose( + (ctr[base] - ctr[plus])[partial], + (WITHDRAWAL_RATE * (income[plus] - income[base]))[partial], + atol=0.01, + ), change + if PARTIAL_AWARD_PENSIONER in families: + assert partial[families.index(PARTIAL_AWARD_PENSIONER)] + + +@st.composite +def war_pensions(draw): + return { + "afcs_reported": draw(money(15_000)), + "war_widows_pension_reported": draw(st.one_of(st.just(0.0), money(8_000))), + } + + +def local_war_pension_income(payments, area): + """Counted war pensions under Kingston's Sch 8 para 20 and 40 or + Newham's Sch 8 para 19, from each adult's payments.""" + amounts = [x for person in payments for x in person.values()] + disregard = sum(min(x, LOCAL_WAR_PENSION_DISREGARD) for x in amounts) + if area == "KINGSTON_UPON_THAMES": + disregard = min(disregard, KINGSTON_DISREGARD_LIMIT) + return sum(amounts) - disregard + + +@PROPERTY_SETTINGS +@given(st.data()) +def test_war_pensions_count_only_where_the_scheme_says(data): + # Every draw ends with the Kingston couple, whose four payments meet the + # £20 a week limit, so the local branch is always exercised. + families = data.draw(st.lists(any_family, min_size=1, max_size=6)) + families = families + [KINGSTON_COUPLE] + payments = [[data.draw(war_pensions()) for _ in fam["ages"]] for fam in families] + payments[-1] = [ + {"afcs_reported": 5_000.0, "war_widows_pension_reported": 3_000.0} + ] * 2 + variants = [] + for fam, pay in zip(families, payments): + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, dict(enumerate(pay)))) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + income = v["council_tax_reduction_applicable_income"] + r = route(v) + pensioner = v["council_tax_reduction_pensioner"].astype(bool) + general = (r[base] == 0) & (r[plus] == 0) & (income[base] > 0) + expected = np.array( + [ + local_war_pension_income(pay, fam["area"]) + if fam["area"] in LOCAL_WAR_PENSION_AREAS and not pensioner[2 * k] + else 0.0 + for k, (fam, pay) in enumerate(zip(families, payments)) + ] + ) + rise = income[plus] - income[base] + assert np.allclose(rise[general], expected[general], atol=0.01), [ + (families[k]["area"], payments[k], rise[k], expected[k]) + for k in np.flatnonzero(general & ~np.isclose(rise, expected, atol=0.01))[:3] + ] + # The Kingston couple's four payments meet the £20 a week limit. + assert np.isclose(rise[-1], 16_000 - KINGSTON_DISREGARD_LIMIT, atol=0.01) From f0273113faebf9635ca458fd7d154784901ab637 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 00:17:36 -0400 Subject: [PATCH 070/141] Apply the Kingston and Newham war pension disregard at pension age too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each council's pensioner schedule (Sch 6 para 1) disregards only £10 a week of each war pension, with no family limit; Kingston's £20 a week limit (Sch 8 para 40) is a working-age rule. Elsewhere the national pensioner scheme keeps the full disregard. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- .../ctr-income-prescribed-benefits.fixed.md | 2 +- .../means_test/war_pension_disregard.yaml | 6 +- .../means_test/war_pension_disregard.yaml | 6 +- ...cil_tax_reduction_prescribed_benefits.yaml | 68 +++++++++++++++++-- ...eduction_prescribed_benefits_properties.py | 31 +++++---- ...council_tax_reduction_applicable_income.py | 38 ++++++----- 6 files changed, 110 insertions(+), 41 deletions(-) diff --git a/changelog.d/ctr-income-prescribed-benefits.fixed.md b/changelog.d/ctr-income-prescribed-benefits.fixed.md index 8a8df3b7b2..3915edbe55 100644 --- a/changelog.d/ctr-income-prescribed-benefits.fixed.md +++ b/changelog.d/ctr-income-prescribed-benefits.fixed.md @@ -1 +1 @@ -Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57), and counts war pensions and Armed Forces Compensation Scheme payments less £10 a week each under the Kingston upon Thames and Newham working-age schemes. +Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57), and counts war pensions and Armed Forces Compensation Scheme payments less £10 a week each under the Kingston upon Thames and Newham schemes. diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml index 08a5de490a..4601fb6c5b 100644 --- a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml @@ -1,4 +1,4 @@ -description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from a working-age applicant's income under the Kingston upon Thames Council Tax Reduction scheme. +description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from an applicant's income under the Kingston upon Thames Council Tax Reduction scheme, at working age and at pension age. values: 2026-04-01: 10 metadata: @@ -6,5 +6,7 @@ metadata: period: week label: Kingston upon Thames Council Tax Reduction war pension disregard reference: - - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 20 (PDF page 136) + - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 20 (persons who are not pensioners, PDF page 136) href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136 + - title: Council Tax Reduction Scheme 2026 - 2027, Sch 6 para 1 (pensioners, PDF page 127) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=127 diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml index 48e1dd254e..966221539d 100644 --- a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml @@ -1,4 +1,4 @@ -description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from a working-age applicant's income under the Newham Council Tax Reduction scheme. +description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from an applicant's income under the Newham Council Tax Reduction scheme, at working age and at pension age. values: 2026-04-01: 10 metadata: @@ -6,5 +6,7 @@ metadata: period: week label: Newham Council Tax Reduction war pension disregard reference: - - title: Council Tax Reduction Scheme 2026, Sch 8 para 19 (PDF page 292) + - title: Council Tax Reduction Scheme 2026, Sch 8 para 19 (persons who are not pensioners, PDF page 292) href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292 + - title: Council Tax Reduction Scheme 2026, Sch 6 para 1 (pensioners, PDF page 273) + href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=273 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index cdb1658fb6..ce66ea45fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -24,10 +24,12 @@ # exceed (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and # s.13A(1)(c)). The model takes the full disregard where it cannot see the # council's choice. -# - Of the English working-age schemes the model runs, Westminster's, Oxford's -# and Merton's disregard war pensions in full. Kingston upon Thames (Sch 8 -# para 20) and Newham (Sch 8 para 19) disregard £10 a week of each payment; -# Kingston limits the family's total to £20 a week (Sch 8 para 40). +# - Of the councils whose schemes the model reads, Westminster's, Oxford's and +# Merton's working-age schemes disregard war pensions in full. Kingston upon +# Thames and Newham disregard £10 a week of each payment at working age +# (Kingston Sch 8 para 20, Newham Sch 8 para 19) and at pension age (each +# council's Sch 6 para 1). Kingston limits a working-age family's total to +# £20 a week (Sch 8 para 40); neither pensioner schedule has a limit. # # Weekly rates, 2026-27 (SI 2026/148 Sch 1, restating SSCBA 1992 Sch 4): # disablement pension at 100% 233.90, 50% 116.95, 20% 46.78; long-term @@ -485,7 +487,7 @@ output: council_tax_reduction_applicable_income: 0 -- name: The national pensioner scheme takes the full war pension disregard in Kingston upon Thames +- name: The Kingston upon Thames scheme counts a pensioner's war pension less 10 a week period: 2026 absolute_error_margin: 0.01 input: @@ -505,6 +507,62 @@ local_authority: KINGSTON_UPON_THAMES council_tax: 2_000 savings: 0 + output: + council_tax_reduction_pensioner: true + # Sch 6 para 1: 5,000 - 520. + council_tax_reduction_applicable_income: 5_000 - 10 * 52 + +- name: The Kingston upon Thames pensioner schedule has no family limit on war pension disregards + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + partner: + age: 70 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [pensioner, partner] + would_claim_pc: false + households: + household: + members: [pensioner, partner] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: true + # Four payments, four disregards of 520; Sch 8 para 40 is a working-age + # limit: 16,000 - 2,080. + council_tax_reduction_applicable_income: 16_000 - 4 * 10 * 52 + +- name: The national pensioner scheme takes the full war pension disregard outside the councils the model reads + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + afcs_reported: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 output: council_tax_reduction_pensioner: true council_tax_reduction_applicable_income: 0 diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py index 31ea275684..11691d4e48 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -23,9 +23,11 @@ them (SSI 2012/319 Sch 3 para 1; SSI 2021/249 reg 57(1)), and English and Welsh councils may disregard more than the prescribed £10 a week (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and s.13A(1)(c)). Kingston upon Thames -(Sch 8 para 20) and Newham (Sch 8 para 19) disregard only £10 a week of each -payment at working age, and Kingston limits the family's total to £20 a week -(Sch 8 para 40). Westminster, Merton and Oxford disregard them in full. +and Newham disregard only £10 a week of each payment, at working age (Kingston +Sch 8 para 20, Newham Sch 8 para 19) and at pension age (each council's Sch 6 +para 1), and Kingston limits a working-age family's total to £20 a week (Sch 8 +para 40). Westminster's, Merton's and Oxford's working-age schemes disregard +them in full. Invariants, in 2026, for single people and couples with no children: @@ -47,10 +49,11 @@ strictly between nil and the liability, it falls by 20% of the rise in income. 3. War pensions: adding war pensions leaves Council Tax Reduction income - unchanged under the general rules, except under the Kingston upon Thames - and Newham working-age schemes, where it rises by the payments less £10 a - week of each (in Kingston, less at most £20 a week in all). The disregards - are written out here from the schemes, not read from the parameters. + unchanged under the general rules, except in Kingston upon Thames and + Newham, where it rises by the payments less £10 a week of each (for a + working-age family in Kingston, less at most £20 a week in all). The + disregards are written out here from the schemes, not read from the + parameters. These compare the model's own measures. Invariant 1 relies on both measures deducting the family's whole income tax and National Insurance, an existing @@ -84,6 +87,7 @@ PENSION_AGE_AREAS = { "MAIDSTONE": ("ENGLAND", "SOUTH_EAST"), "KINGSTON_UPON_THAMES": ("ENGLAND", "LONDON"), + "NEWHAM": ("ENGLAND", "LONDON"), "CARDIFF": ("WALES", "WALES"), "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), } @@ -370,12 +374,13 @@ def war_pensions(draw): } -def local_war_pension_income(payments, area): - """Counted war pensions under Kingston's Sch 8 para 20 and 40 or - Newham's Sch 8 para 19, from each adult's payments.""" +def local_war_pension_income(payments, area, pensioner): + """Counted war pensions under Kingston's or Newham's scheme (Sch 6 para + 1 at pension age; Kingston Sch 8 paras 20 and 40 and Newham Sch 8 para 19 + at working age), from each adult's payments.""" amounts = [x for person in payments for x in person.values()] disregard = sum(min(x, LOCAL_WAR_PENSION_DISREGARD) for x in amounts) - if area == "KINGSTON_UPON_THAMES": + if area == "KINGSTON_UPON_THAMES" and not pensioner: disregard = min(disregard, KINGSTON_DISREGARD_LIMIT) return sum(amounts) - disregard @@ -403,8 +408,8 @@ def test_war_pensions_count_only_where_the_scheme_says(data): general = (r[base] == 0) & (r[plus] == 0) & (income[base] > 0) expected = np.array( [ - local_war_pension_income(pay, fam["area"]) - if fam["area"] in LOCAL_WAR_PENSION_AREAS and not pensioner[2 * k] + local_war_pension_income(pay, fam["area"], pensioner[2 * k]) + if fam["area"] in LOCAL_WAR_PENSION_AREAS else 0.0 for k, (fam, pay) in enumerate(zip(families, payments)) ] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index b37764428a..a3189661be 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -1,7 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_working_age, - is_newham_working_age, + is_kingston_upon_thames_area, + is_newham_area, ) from policyengine_uk.variables.household.demographic.country import Country @@ -25,10 +25,10 @@ class council_tax_reduction_applicable_income(Variable): "injuries disablement benefit, severe disablement allowance and " "incapacity benefit count in every scheme. War pensions and Armed " "Forces Compensation Scheme payments count, less £10 a week each, " - "only under the Kingston upon Thames and Newham working-age schemes: " - "Scotland disregards or omits them, and elsewhere in England and " - "Wales councils may disregard them beyond the prescribed £10 a week, " - "so the model takes the full disregard." + "only under the Kingston upon Thames and Newham schemes: Scotland " + "disregards or omits them, and elsewhere in England and Wales " + "councils may disregard them beyond the prescribed £10 a week, so the " + "model takes the full disregard." ) definition_period = YEAR unit = GBP @@ -55,6 +55,8 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136", "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292", + "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=127", + "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=273", ] def formula(benunit, period, parameters): @@ -147,13 +149,15 @@ def formula(benunit, period, parameters): # schemes under SSAA 1992 s.134(8)(a). Reg 34(5) does not cover # guaranteed income payments, so in Wales that over-disregards them, # but afcs does not separate them from war disablement pensions. - # Of the English working-age schemes the model runs, Westminster's, - # Oxford's and Merton's disregard war pensions in full. Kingston upon - # Thames (Sch 8 para 20) and Newham (Sch 8 para 19) disregard £10 a - # week of each payment, the reading Pension Credit's identically - # worded disregard takes, and Kingston limits the family's total to - # £20 a week (Sch 8 para 40). Newham's limit (its para 39) names its - # paras 20 and 21, not the war pension para 19, so it does not apply. + # Of the councils whose schemes the model reads, Westminster's, + # Oxford's and Merton's working-age schemes disregard war pensions in + # full. Kingston upon Thames and Newham disregard only £10 a week of + # each payment, the reading Pension Credit's identically worded + # disregard takes, at working age (Kingston Sch 8 para 20, Newham Sch 8 + # para 19) and at pension age (each council's Sch 6 para 1). Kingston + # limits a working-age family's total to £20 a week (Sch 8 para 40); + # Newham's limit (its para 39) names its paras 20 and 21, not the war + # pension para 19, and neither pensioner schedule has a limit. income_components = [ "employment_income", "self_employment_income", @@ -188,10 +192,8 @@ def formula(benunit, period, parameters): increased_income = income + personal_benefit_income + credits + benefits local_authority = benunit.household("local_authority", period) - kingston = is_kingston_upon_thames_working_age( - local_authority, country, pensioner - ) - newham = is_newham_working_age(local_authority, country, pensioner) + kingston = is_kingston_upon_thames_area(local_authority, country) + newham = is_newham_area(local_authority, country) local_authorities = parameters(period).gov.local_authorities kingston_test = ( local_authorities.kingston_upon_thames.council_tax_reduction.means_test @@ -211,7 +213,7 @@ def formula(benunit, period, parameters): min_(amount, benunit.project(payment_disregard)) ) war_pension_disregards = where( - kingston, + kingston & ~pensioner, min_( war_pension_disregards, kingston_test.war_pension_disregard_limit * WEEKS_IN_YEAR, From e135b13ab8236f70f36ab756789b297a42bd4475 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 00:41:19 -0400 Subject: [PATCH 071/141] Keep war pensions uncounted and count only the applicant's and partner's benefits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of f0273113f found that Newham's s.13A(1)(c) policy (para 2.1) adds, automatically, the reduction a full disregard of war compensation payments would give, and Kingston upon Thames's para 36(6) speaks of a full disregard while its schedules keep the £10. So the £10 branch for those councils overstated their council tax; war pensions stay uncounted everywhere, with each council's text noted. IIDB, SDA and IB now count for the applicant and partner only (SI 2012/2885 Sch 1 para 11; SSI 2021/249 reg 36(1)), not for dependants that the wider member mask still reaches. The award cases asserting the 256.00 pensioner allowance use claimants who reached pensionable age before April 2021. New properties: war pensions never count, and dependants' benefits never count. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- .../ctr-income-prescribed-benefits.fixed.md | 2 +- .../means_test/war_pension_disregard.yaml | 12 -- .../war_pension_disregard_limit.yaml | 10 - .../means_test/war_pension_disregard.yaml | 12 -- ...cil_tax_reduction_prescribed_benefits.yaml | 202 ++++-------------- ...eduction_prescribed_benefits_properties.py | 123 ++++++----- ...council_tax_reduction_applicable_income.py | 126 +++++------ 7 files changed, 157 insertions(+), 330 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml delete mode 100644 policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml delete mode 100644 policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml diff --git a/changelog.d/ctr-income-prescribed-benefits.fixed.md b/changelog.d/ctr-income-prescribed-benefits.fixed.md index 3915edbe55..8591acdafc 100644 --- a/changelog.d/ctr-income-prescribed-benefits.fixed.md +++ b/changelog.d/ctr-income-prescribed-benefits.fixed.md @@ -1 +1 @@ -Council Tax Reduction income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57), and counts war pensions and Armed Forces Compensation Scheme payments less £10 a week each under the Kingston upon Thames and Newham schemes. +Council Tax Reduction income now counts the applicant's and partner's industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml deleted file mode 100644 index 4601fb6c5b..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard.yaml +++ /dev/null @@ -1,12 +0,0 @@ -description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from an applicant's income under the Kingston upon Thames Council Tax Reduction scheme, at working age and at pension age. -values: - 2026-04-01: 10 -metadata: - unit: currency-GBP - period: week - label: Kingston upon Thames Council Tax Reduction war pension disregard - reference: - - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 20 (persons who are not pensioners, PDF page 136) - href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136 - - title: Council Tax Reduction Scheme 2026 - 2027, Sch 6 para 1 (pensioners, PDF page 127) - href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=127 diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml deleted file mode 100644 index 2a50f0ff9f..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/war_pension_disregard_limit.yaml +++ /dev/null @@ -1,10 +0,0 @@ -description: Weekly limit on a working-age family's total disregards under Sch 8 paras 20 and 21 (war pensions and widowed parent's allowance) and the student income disregards, under the Kingston upon Thames Council Tax Reduction scheme. -values: - 2026-04-01: 20 -metadata: - unit: currency-GBP - period: week - label: Kingston upon Thames Council Tax Reduction war pension disregard limit - reference: - - title: Council Tax Reduction Scheme 2026 - 2027, Sch 8 para 40 (PDF page 139) - href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=139 diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml deleted file mode 100644 index 966221539d..0000000000 --- a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/war_pension_disregard.yaml +++ /dev/null @@ -1,12 +0,0 @@ -description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from an applicant's income under the Newham Council Tax Reduction scheme, at working age and at pension age. -values: - 2026-04-01: 10 -metadata: - unit: currency-GBP - period: week - label: Newham Council Tax Reduction war pension disregard - reference: - - title: Council Tax Reduction Scheme 2026, Sch 8 para 19 (persons who are not pensioners, PDF page 292) - href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292 - - title: Council Tax Reduction Scheme 2026, Sch 6 para 1 (pensioners, PDF page 273) - href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=273 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index ce66ea45fd..94b91ccdbd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -24,12 +24,16 @@ # exceed (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and # s.13A(1)(c)). The model takes the full disregard where it cannot see the # council's choice. -# - Of the councils whose schemes the model reads, Westminster's, Oxford's and -# Merton's working-age schemes disregard war pensions in full. Kingston upon -# Thames and Newham disregard £10 a week of each payment at working age -# (Kingston Sch 8 para 20, Newham Sch 8 para 19) and at pension age (each -# council's Sch 6 para 1). Kingston limits a working-age family's total to -# £20 a week (Sch 8 para 40); neither pensioner schedule has a limit. +# - Of the councils whose schemes the model reads, Westminster disregards war +# pensions in full and Oxford ignores them for applicants without Universal +# Credit. Newham's scheme keeps the £10, but its discretionary reduction +# policy (para 2.1) automatically adds what a full disregard would give. +# Kingston upon Thames's schedules keep the £10 while its para 36(6) speaks +# of a full disregard, and Merton's "in full" disregard is "subject to +# paragraph 40" (a £20 a week limit). The model approximates all of them +# with the full disregard. +# - Only the applicant's and partner's IIDB, SDA and IB count (SI 2012/2885 +# Sch 1 para 11; SSI 2021/249 reg 36(1)), not a dependant's. # # Weekly rates, 2026-27 (SI 2026/148 Sch 1, restating SSCBA 1992 Sch 4): # disablement pension at 100% 233.90, 50% 116.95, 20% 46.78; long-term @@ -38,9 +42,13 @@ # 241.30 (SI 2026/148 art 6(1)), 12,547.60 a year, below the 12,570 personal # allowance. IIDB, SDA and war pensions are not taxed in the model; State # Pension and IB are. Pension Credit standard minimum guarantee 238.00 -# (single). CTR applicable amounts: personal allowance 256.00 (single, pension -# age) and 95.55 (single, 25 or over); the reduction is withdrawn at 20% of -# income above the applicable amount. Council tax is 2,000 throughout. +# (single). CTR applicable amounts: personal allowance 256.00 for a single +# person who reached pensionable age before 1 April 2021 (SI 2012/2885 Sch 2 +# para 1, as uprated by SI 2026/27 reg 6; SSI 2026/56 reg 6 in Scotland), so +# the pensioners with an award asserted below are 80; and 95.55 for a single +# person aged 25 or over in Scotland's working-age scheme (SSI 2026/56 reg +# 14). The reduction is withdrawn at 20% of income above the applicable +# amount. Council tax is 2,000 throughout. - name: IIDB counts as pension-age Council Tax Reduction income in England period: 2026 @@ -48,7 +56,7 @@ input: people: pensioner: - age: 70 + age: 80 state_pension: 241.30 * 52 iidb_reported: 116.95 * 52 benunits: @@ -104,7 +112,7 @@ input: people: pensioner: - age: 70 + age: 80 state_pension: 241.30 * 52 iidb_reported: 46.78 * 52 benunits: @@ -331,86 +339,7 @@ output: council_tax_reduction_applicable_income: 0 -- name: The Kingston upon Thames working-age scheme counts a war pension less 10 a week - period: 2026 - absolute_error_margin: 0.01 - input: - people: - claimant: - age: 40 - afcs_reported: 5_000 - benunits: - benunit: - members: [claimant] - would_claim_uc: false - households: - household: - members: [claimant] - country: ENGLAND - region: LONDON - local_authority: KINGSTON_UPON_THAMES - council_tax: 2_000 - savings: 0 - output: - # 5,000 - 520 - council_tax_reduction_applicable_income: 5_000 - 10 * 52 - -- name: The Kingston upon Thames scheme disregards 10 a week of each war pension - period: 2026 - absolute_error_margin: 0.01 - input: - people: - claimant: - age: 40 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - benunits: - benunit: - members: [claimant] - would_claim_uc: false - households: - household: - members: [claimant] - country: ENGLAND - region: LONDON - local_authority: KINGSTON_UPON_THAMES - council_tax: 2_000 - savings: 0 - output: - # 8,000 - 2 x 520: two payments, two disregards, within the 20 a week limit. - council_tax_reduction_applicable_income: 8_000 - 2 * 10 * 52 - -- name: The Kingston upon Thames scheme limits a family's war pension disregards to 20 a week - period: 2026 - absolute_error_margin: 0.01 - input: - people: - claimant: - age: 40 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - partner: - age: 40 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - benunits: - benunit: - members: [claimant, partner] - would_claim_uc: false - households: - household: - members: [claimant, partner] - country: ENGLAND - region: LONDON - local_authority: KINGSTON_UPON_THAMES - council_tax: 2_000 - savings: 0 - output: - # Four payments would give 4 x 10 a week; Sch 8 para 40 limits the - # family's total to 20 a week: 16,000 - 1,040. - council_tax_reduction_applicable_income: 16_000 - 20 * 52 - -- name: The Newham working-age scheme disregards 10 a week of each war pension, with no family limit +- name: The Westminster working-age scheme disregards war pensions in full period: 2026 absolute_error_margin: 0.01 input: @@ -419,35 +348,6 @@ age: 40 afcs_reported: 5_000 war_widows_pension_reported: 3_000 - partner: - age: 40 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - benunits: - benunit: - members: [claimant, partner] - would_claim_uc: false - households: - household: - members: [claimant, partner] - country: ENGLAND - region: LONDON - local_authority: NEWHAM - council_tax: 2_000 - savings: 0 - output: - # Newham's limit (its Sch 8 para 39) names paras 20 and 21, not the war - # pension para 19: 16,000 - 4 x 520. - council_tax_reduction_applicable_income: 16_000 - 4 * 10 * 52 - -- name: A war pension below 10 a week counts as nothing under the Newham scheme - period: 2026 - absolute_error_margin: 0.01 - input: - people: - claimant: - age: 40 - afcs_reported: 300 benunits: benunit: members: [claimant] @@ -457,13 +357,13 @@ members: [claimant] country: ENGLAND region: LONDON - local_authority: NEWHAM + local_authority: WESTMINSTER council_tax: 2_000 savings: 0 output: council_tax_reduction_applicable_income: 0 -- name: The Westminster working-age scheme disregards war pensions in full +- name: War pensions do not count under the Kingston upon Thames working-age scheme period: 2026 absolute_error_margin: 0.01 input: @@ -481,13 +381,13 @@ members: [claimant] country: ENGLAND region: LONDON - local_authority: WESTMINSTER + local_authority: KINGSTON_UPON_THAMES council_tax: 2_000 savings: 0 output: council_tax_reduction_applicable_income: 0 -- name: The Kingston upon Thames scheme counts a pensioner's war pension less 10 a week +- name: War pensions do not count for a pensioner in Newham period: 2026 absolute_error_margin: 0.01 input: @@ -504,68 +404,40 @@ members: [pensioner] country: ENGLAND region: LONDON - local_authority: KINGSTON_UPON_THAMES - council_tax: 2_000 - savings: 0 - output: - council_tax_reduction_pensioner: true - # Sch 6 para 1: 5,000 - 520. - council_tax_reduction_applicable_income: 5_000 - 10 * 52 - -- name: The Kingston upon Thames pensioner schedule has no family limit on war pension disregards - period: 2026 - absolute_error_margin: 0.01 - input: - people: - pensioner: - age: 70 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - partner: - age: 70 - afcs_reported: 5_000 - war_widows_pension_reported: 3_000 - benunits: - benunit: - members: [pensioner, partner] - would_claim_pc: false - households: - household: - members: [pensioner, partner] - country: ENGLAND - region: LONDON - local_authority: KINGSTON_UPON_THAMES + local_authority: NEWHAM council_tax: 2_000 savings: 0 output: council_tax_reduction_pensioner: true - # Four payments, four disregards of 520; Sch 8 para 40 is a working-age - # limit: 16,000 - 2,080. - council_tax_reduction_applicable_income: 16_000 - 4 * 10 * 52 + council_tax_reduction_applicable_income: 0 -- name: The national pensioner scheme takes the full war pension disregard outside the councils the model reads +- name: A dependant's IIDB does not count as Council Tax Reduction income period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 - afcs_reported: 5_000 + age: 80 + state_pension: 241.30 * 52 + dependant: + age: 17 + iidb_reported: 46.78 * 52 benunits: benunit: - members: [pensioner] + members: [pensioner, dependant] would_claim_pc: false households: household: - members: [pensioner] + members: [pensioner, dependant] country: ENGLAND region: SOUTH_EAST local_authority: MAIDSTONE council_tax: 2_000 savings: 0 output: - council_tax_reduction_pensioner: true - council_tax_reduction_applicable_income: 0 + # Only the applicant's and partner's income counts (Sch 1 para 11); + # pensioner schemes also exclude Child Benefit. + council_tax_reduction_applicable_income: 241.30 * 52 - name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income period: 2026 diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py index 11691d4e48..037f5d96f5 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -18,18 +18,21 @@ guarantee credit passport ends, Council Tax Reduction was assessed on less income than Pension Credit and Housing Benefit. -War pensions and Armed Forces Compensation Scheme payments stay uncounted -where the model cannot see the council's choice: Scotland disregards or omits -them (SSI 2012/319 Sch 3 para 1; SSI 2021/249 reg 57(1)), and English and -Welsh councils may disregard more than the prescribed £10 a week (WSI 2013/3029 -reg 34(5); LGFA 1992 Sch 1A para 2(11) and s.13A(1)(c)). Kingston upon Thames -and Newham disregard only £10 a week of each payment, at working age (Kingston -Sch 8 para 20, Newham Sch 8 para 19) and at pension age (each council's Sch 6 -para 1), and Kingston limits a working-age family's total to £20 a week (Sch 8 -para 40). Westminster's, Merton's and Oxford's working-age schemes disregard -them in full. - -Invariants, in 2026, for single people and couples with no children: +Only the applicant's and partner's benefits count (SI 2012/2885 Sch 1 para 11; +SSI 2021/249 reg 36(1)), not a dependant's. + +War pensions and Armed Forces Compensation Scheme payments stay uncounted. +Scotland disregards or omits them (SSI 2012/319 Sch 3 para 1; SSI 2021/249 reg +57(1)), and English and Welsh councils may relieve more than the prescribed +£10 a week (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and +s.13A(1)(c)). The councils whose schemes the model reads mostly do: Westminster +and Oxford disregard them in full, Newham's discretionary policy adds what a +full disregard would give, and Kingston upon Thames's and Merton's texts point +both ways. The model approximates all of them with the full disregard. + +Invariants, in 2026, for single people and couples not claiming Universal +Credit (whose award would move with the benefits), with no children except in +invariant 4: 1. Differential: Council Tax Reduction and Pension Credit assess the same income. For pension-age families under the national pensioner schemes in @@ -48,12 +51,12 @@ credit) is unchanged; in the national schemes, where both awards are strictly between nil and the liability, it falls by 20% of the rise in income. -3. War pensions: adding war pensions leaves Council Tax Reduction income - unchanged under the general rules, except in Kingston upon Thames and - Newham, where it rises by the payments less £10 a week of each (for a - working-age family in Kingston, less at most £20 a week in all). The - disregards are written out here from the schemes, not read from the - parameters. +3. War pensions: adding war pensions and Armed Forces Compensation Scheme + payments leaves Council Tax Reduction income unchanged under the general + rules, in every scheme. +4. Dependants: giving a dependant aged 16 to 19 IIDB, IB within the personal + allowance, or SDA leaves Council Tax Reduction income unchanged under the + general rules, in every scheme. These compare the model's own measures. Invariant 1 relies on both measures deducting the family's whole income tax and National Insurance, an existing @@ -73,9 +76,6 @@ LONG_TERM_IB = 146.60 * WEEKS SDA = (103.85 + 15.50) * WEEKS WITHDRAWAL_RATE = 0.2 -# £10 a week of each war pension; Kingston's £20 a week family limit. -LOCAL_WAR_PENSION_DISREGARD = 10 * WEEKS -KINGSTON_DISREGARD_LIMIT = 20 * WEEKS PRIVATE_PENSIONS = np.arange(0, 20_001, 1_000) PROPERTY_SETTINGS = settings( max_examples=10, @@ -101,7 +101,6 @@ "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), } NATIONAL_SCHEME_AREAS = {"MAIDSTONE", "CARDIFF", "GLASGOW_CITY"} -LOCAL_WAR_PENSION_AREAS = {"KINGSTON_UPON_THAMES", "NEWHAM"} BENUNIT_VARIABLES = [ "council_tax_reduction_applicable_income", "council_tax_benefit", @@ -189,8 +188,7 @@ def working_age_family(draw): savings=0.0, would_claim_pc=False, ) -# Two adults on the Kingston upon Thames working-age scheme, so the £20 limit -# binds once each has two war pensions. +# A working-age couple on the Kingston upon Thames scheme. KINGSTON_COUPLE = dict( ages=[40, 40], state_pensions=[0.0, 0.0], @@ -237,6 +235,13 @@ def situation(variants): person[variable] = {YEAR: value} people[name] = person names.append(name) + if fam.get("dependant_age") is not None: + j = len(fam["ages"]) + name = f"p{i}_{j}" + people[name] = {"age": {YEAR: fam["dependant_age"]}} + for variable, value in extra.get(j, {}).items(): + people[name][variable] = {YEAR: value} + names.append(name) benunits[f"b{i}"] = { "members": names, "would_claim_pc": {YEAR: fam["would_claim_pc"]}, @@ -374,22 +379,11 @@ def war_pensions(draw): } -def local_war_pension_income(payments, area, pensioner): - """Counted war pensions under Kingston's or Newham's scheme (Sch 6 para - 1 at pension age; Kingston Sch 8 paras 20 and 40 and Newham Sch 8 para 19 - at working age), from each adult's payments.""" - amounts = [x for person in payments for x in person.values()] - disregard = sum(min(x, LOCAL_WAR_PENSION_DISREGARD) for x in amounts) - if area == "KINGSTON_UPON_THAMES" and not pensioner: - disregard = min(disregard, KINGSTON_DISREGARD_LIMIT) - return sum(amounts) - disregard - - @PROPERTY_SETTINGS @given(st.data()) -def test_war_pensions_count_only_where_the_scheme_says(data): - # Every draw ends with the Kingston couple, whose four payments meet the - # £20 a week limit, so the local branch is always exercised. +def test_war_pensions_never_count(data): + # Every draw includes the Kingston couple, so a council whose schedules + # keep the £10 is always exercised. families = data.draw(st.lists(any_family, min_size=1, max_size=6)) families = families + [KINGSTON_COUPLE] payments = [[data.draw(war_pensions()) for _ in fam["ages"]] for fam in families] @@ -404,20 +398,43 @@ def test_war_pensions_count_only_where_the_scheme_says(data): base, plus = slice(0, None, 2), slice(1, None, 2) income = v["council_tax_reduction_applicable_income"] r = route(v) - pensioner = v["council_tax_reduction_pensioner"].astype(bool) - general = (r[base] == 0) & (r[plus] == 0) & (income[base] > 0) - expected = np.array( - [ - local_war_pension_income(pay, fam["area"], pensioner[2 * k]) - if fam["area"] in LOCAL_WAR_PENSION_AREAS - else 0.0 - for k, (fam, pay) in enumerate(zip(families, payments)) - ] - ) + general = (r[base] == 0) & (r[plus] == 0) + assert general[-1] + rise = income[plus] - income[base] + assert np.allclose(rise[general], 0, atol=0.01), [ + (families[k]["area"], payments[k], rise[k]) + for k in np.flatnonzero(general & ~np.isclose(rise, 0, atol=0.01))[:3] + ] + + +DEPENDANT_CHANGES = { + "iidb": {"iidb_reported": 0.5 * IIDB_FULL}, + "incapacity_benefit": {"incapacity_benefit_reported": LONG_TERM_IB}, + "sda": {"sda_reported": 1.0}, +} + + +@PROPERTY_SETTINGS +@given( + st.lists(any_family, min_size=1, max_size=6), + st.sampled_from(sorted(DEPENDANT_CHANGES)), + st.integers(16, 19), +) +@example([GENERAL_RULES_PENSIONER, KINGSTON_COUPLE], "iidb", 17) +def test_dependants_benefits_never_count(families, change, dependant_age): + families = [{**fam, "dependant_age": dependant_age} for fam in families] + variants = [] + for fam in families: + dependant = len(fam["ages"]) + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, {dependant: DEPENDANT_CHANGES[change]})) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + income = v["council_tax_reduction_applicable_income"] + r = route(v) + general = (r[base] == 0) & (r[plus] == 0) rise = income[plus] - income[base] - assert np.allclose(rise[general], expected[general], atol=0.01), [ - (families[k]["area"], payments[k], rise[k], expected[k]) - for k in np.flatnonzero(general & ~np.isclose(rise, expected, atol=0.01))[:3] + assert np.allclose(rise[general], 0, atol=0.01), [ + (families[k]["area"], families[k]["ages"], change, rise[k]) + for k in np.flatnonzero(general & ~np.isclose(rise, 0, atol=0.01))[:3] ] - # The Kingston couple's four payments meet the £20 a week limit. - assert np.isclose(rise[-1], 16_000 - KINGSTON_DISREGARD_LIMIT, atol=0.01) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index a3189661be..848f414049 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -1,8 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_area, - is_newham_area, -) from policyengine_uk.variables.household.demographic.country import Country @@ -23,12 +19,11 @@ class council_tax_reduction_applicable_income(Variable): "provisions allow (childcare charges, the £20 earnings disregards, " "maintenance disregards and the rest) are not modelled. Industrial " "injuries disablement benefit, severe disablement allowance and " - "incapacity benefit count in every scheme. War pensions and Armed " - "Forces Compensation Scheme payments count, less £10 a week each, " - "only under the Kingston upon Thames and Newham schemes: Scotland " - "disregards or omits them, and elsewhere in England and Wales " - "councils may disregard them beyond the prescribed £10 a week, so the " - "model takes the full disregard." + "incapacity benefit count in every scheme, for the applicant and " + "partner. War pensions and Armed Forces Compensation Scheme payments " + "are not counted: Scotland disregards or omits them, and English and " + "Welsh councils may relieve them beyond the prescribed £10 a week, so " + "the model takes the full disregard." ) definition_period = YEAR unit = GBP @@ -53,10 +48,8 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/17", "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/1", "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", - "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=136", - "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=292", - "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf#page=127", - "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026#page=273", + "https://www.legislation.gov.uk/ukpga/1992/14/section/13A", + "https://www.newham.gov.uk/downloads/file/209/discretionary-council-tax-reductions-for-individual-council-tax-payers", ] def formula(benunit, period, parameters): @@ -116,23 +109,26 @@ def formula(benunit, period, parameters): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", - # Every scheme the model runs counts these three benefits. At - # pension age they are social security benefits that the income - # lists do not except: SI 2012/2885 Sch 1 para 16(1)(j), the - # default scheme SI 2012/2886 Sch para 39(1)(j), WSI 2013/3029 - # Sch 1 para 10(1)(j) and SSI 2012/319 reg 27(1)(j). At working - # age England and Wales count gross income (SI 2012/2886 Sch para - # 54(1); WSI 2013/3029 Sch 6 para 17(1)) less disregards that do - # not list them (their Schs 8 and 9), and Scotland's closed list - # names them (SSI 2021/249 reg 57(1)(b)(ix) to (xi)). Each scheme - # leaves out IIDB's constant attendance and exceptionally severe - # disablement increases (SSCBA 1992 ss.104 and 105), by name or as - # "attendance allowance" (Sch 8 and Sch 9 para 14), but iidb is - # the whole reported payment and cannot separate them. - "iidb", - "sda", - "incapacity_benefit", ] + # Every scheme the model runs counts these three benefits. At pension + # age they are social security benefits that the income lists do not + # except: SI 2012/2885 Sch 1 para 16(1)(j), the default scheme SI + # 2012/2886 Sch para 39(1)(j), WSI 2013/3029 Sch 1 para 10(1)(j) and + # SSI 2012/319 reg 27(1)(j). At working age England and Wales count + # gross income (SI 2012/2886 Sch para 54(1); WSI 2013/3029 Sch 6 para + # 17(1)) less disregards that do not list them (their Schs 8 and 9), + # and Scotland's closed list names them (SSI 2021/249 reg 57(1)(b)(ix) + # to (xi)). Each scheme leaves out IIDB's constant attendance and + # exceptionally severe disablement increases (SSCBA 1992 ss.104 and + # 105), by name or as "attendance allowance" (Sch 8 and Sch 9 para + # 14), but iidb is the whole reported payment and cannot separate + # them. Only the applicant's and partner's count (Sch 1 para 11; SSI + # 2021/249 reg 36(1)), so these leave out the dependants that the + # wider mask above still reaches. + applicant_and_partner_benefits = ["iidb", "sda", "incapacity_benefit"] + applicant_and_partner = ( + person("is_claimant_or_partner", period) | applicant_counted_as_young_person + ) # War disablement pensions, war widow's or widower's pensions and # Armed Forces Compensation Scheme payments (afcs, war_widows_pension). # Scotland counts none of them: SSI 2012/319 Sch 3 para 1 disregards @@ -144,20 +140,26 @@ def formula(benunit, period, parameters): # or widower's pensions (WSI 2013/3029 reg 34(5)), an English scheme # may make provision equivalent to the council tax benefit rules (LGFA # 1992 Sch 1A para 2(11)), and any council may reduce council tax - # further (s.13A(1)(c)). Where the model cannot see the council's - # choice it takes the full disregard, as Housing Benefit does for local - # schemes under SSAA 1992 s.134(8)(a). Reg 34(5) does not cover - # guaranteed income payments, so in Wales that over-disregards them, - # but afcs does not separate them from war disablement pensions. - # Of the councils whose schemes the model reads, Westminster's, - # Oxford's and Merton's working-age schemes disregard war pensions in - # full. Kingston upon Thames and Newham disregard only £10 a week of - # each payment, the reading Pension Credit's identically worded - # disregard takes, at working age (Kingston Sch 8 para 20, Newham Sch 8 - # para 19) and at pension age (each council's Sch 6 para 1). Kingston - # limits a working-age family's total to £20 a week (Sch 8 para 40); - # Newham's limit (its para 39) names its paras 20 and 21, not the war - # pension para 19, and neither pensioner schedule has a limit. + # further (s.13A(1)(c)). The model takes the full disregard, as + # Housing Benefit does for local schemes under SSAA 1992 s.134(8)(a). + # The councils whose schemes the model reads mostly go further: + # Westminster's working-age and pensioner schemes disregard war + # pensions in full, and Oxford's Appendix 5 ignores war pensions for + # applicants without Universal Credit. Newham's scheme keeps the £10 + # (Sch 6 para 1, Sch 8 para 19), but since 2013 it adds automatically + # the reduction a full disregard of war compensation payments would + # give (its s.13A(1)(c) policy, para 2.1). Kingston upon Thames's + # schedules keep the £10 (Sch 6 para 1, Sch 8 para 20) while its para + # 36(6) speaks of "the higher amount of full disregard" of the same + # payments. Merton's Sch 8 para 20 disregards them "in full" but + # "subject to paragraph 40", whose £20 a week limit, read literally, + # caps that at working age. These are approximations of the councils' + # texts. The full disregard also over-disregards guaranteed income + # payments in Wales, which reg 34(5) does not cover, and wherever a + # scheme keeps the prescribed £10 without further relief; afcs does + # not separate guaranteed income payments from war disablement + # pensions, nor either from the constant attendance and severe + # disablement additions that every scheme disregards in full. income_components = [ "employment_income", "self_employment_income", @@ -188,42 +190,12 @@ def formula(benunit, period, parameters): personal_benefit_income = add_for_members( benunit, period, personal_benefits, members ) + personal_benefit_income += add_for_members( + benunit, period, applicant_and_partner_benefits, applicant_and_partner + ) credits = add_for_members(benunit, period, ["tax_credits"], members) increased_income = income + personal_benefit_income + credits + benefits - local_authority = benunit.household("local_authority", period) - kingston = is_kingston_upon_thames_area(local_authority, country) - newham = is_newham_area(local_authority, country) - local_authorities = parameters(period).gov.local_authorities - kingston_test = ( - local_authorities.kingston_upon_thames.council_tax_reduction.means_test - ) - newham_test = local_authorities.newham.council_tax_reduction.means_test - payment_disregard = WEEKS_IN_YEAR * where( - kingston, - kingston_test.war_pension_disregard, - newham_test.war_pension_disregard, - ) - war_pensions = 0 - war_pension_disregards = 0 - for payment in ["afcs", "war_widows_pension"]: - amount = person(payment, period) * members - war_pensions += benunit.sum(amount) - war_pension_disregards += benunit.sum( - min_(amount, benunit.project(payment_disregard)) - ) - war_pension_disregards = where( - kingston & ~pensioner, - min_( - war_pension_disregards, - kingston_test.war_pension_disregard_limit * WEEKS_IN_YEAR, - ), - war_pension_disregards, - ) - increased_income += where( - kingston | newham, war_pensions - war_pension_disregards, 0 - ) - if not bi.interactions.include_in_means_tests: increased_income -= add_for_members( benunit, period, ["basic_income"], members From b224d1e8e8290d31ef3da483f94cb2427716ed94 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 00:43:59 -0400 Subject: [PATCH 072/141] Label the Scottish Carer Support Payment exception in the GC boundary property With IB, SDA and IIDB now counted, Council Tax Reduction can end the guarantee credit passport on more income than Pension Credit counted where an overlapping benefit cuts a Scottish carer's Carer Support Payment: Scottish pension-age CTR counts the payment before the CSP Regs reg 16(2) reduction (SSI 2012/319 reg 27(3) and (5)), Pension Credit after it (SPC Regs reg 15(4)(g)). Those families are left out of the boundary invariant until that reading is decided. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- ...nsion_credit_prescribed_benefits_properties.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py index 67448845bd..cbdfaa5d61 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -54,6 +54,14 @@ household net income, excluding the TV licence fee, does not fall. The free TV licence for over-75s on Pension Credit is a genuine statutory cliff, so it is excluded, as in test_pension_credit_carer_income_properties.py. + Intended exception: Scottish pension-age Council Tax Reduction counts + Carer Support Payment before the CSP Regs reg 16(2) overlapping-benefit + reduction (SSI 2012/319 reg 27(3) and (5); see + council_tax_reduction_applicable_income), while Pension Credit counts it + after (SPC Regs reg 15(4)(g)). Where an overlapping benefit cuts the + payment, the passport ends on more Council Tax Reduction income than + Pension Credit counted, so the step can fall. Those families are left out + until that reading is decided. """ import numpy as np @@ -332,13 +340,20 @@ def test_net_income_does_not_fall_where_guarantee_credit_ends(families): "tv_licence", "housing_benefit", "council_tax_benefit", + "carer_support_payment_pre_overlap", + "carer_support_payment", ], ) step = np.diff(g["household_net_income"] + g["tv_licence"], axis=1) gc = g["guarantee_credit"] ends = (gc[:, :-1] > 0) & (gc[:, 1:] <= 0) + # The intended exception in invariant 4: Carer Support Payment cut by an + # overlapping benefit. + csp_cut = g["carer_support_payment_pre_overlap"] > g["carer_support_payment"] + 0.01 for i, fam in enumerate(families): for k in np.flatnonzero(ends[i]): + if csp_cut[i, k] or csp_cut[i, k + 1]: + continue assert step[i, k] >= -0.01, ( fam, f"private pension {PENSIONS[k]} -> {PENSIONS[k + 1]}", From b12bdf9d8d749f60f95dc4e67cd413171187375e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:50:35 +0100 Subject: [PATCH 073/141] Fix inactive UC health-element counterfactual --- changelog.d/2283.fixed.md | 1 + docs/book/policy/uc-rebalancing.md | 4 +- policyengine_uk/scenarios/uc_reform.py | 72 +++++++- policyengine_uk/tests/test_uc_rebalancing.py | 166 +++++++++++++++++++ 4 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2283.fixed.md diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md new file mode 100644 index 0000000000..9632996671 --- /dev/null +++ b/changelog.d/2283.fixed.md @@ -0,0 +1 @@ +When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit amount and award inputs. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 89ac3d4db3..190889794e 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,7 +24,7 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di Two parameters hold this amount, and both are uprated by `gov.economic_assumptions.indices.uc_health_element`, so they follow the same path: -- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and from 2033-34, after the data's projection ends, when the rebalancing scenario no longer sets the element. +- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. For an inactive year processed by the rebalancing scenario, the scenario replaces that paid amount with the pre-Act counterfactual described below. From 2033-34, after the data's projection ends, the rebalancing scenario no longer sets the element and the formula reads this parameter. - `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants in the years it sets the element, `uc_reform.REBALANCING_YEARS` (2026-27 to 2032-33). A change to the freeze or to the benefit uprating index moves both, and so does a reform that sets the index, `gov.economic_assumptions.indices.uc_health_element`, directly (it replaces the freeze and the no-fall floor for the years it names). A reform to either amount itself does not change the other. @@ -92,6 +92,8 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +For the health component, an inactive year uses the 2025-26 monthly LCWRA amount (£423.27), uprated by the model's ordinary benefit uprating index from 2025-26 to that year. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that pre-Act amount, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. + The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. ### Changing the standard allowance uplift parameters diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index c159821941..55e6209d90 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -2,10 +2,10 @@ from policyengine_uk import Microsimulation from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR -from policyengine_uk.utils.supplied_inputs import set_model_value +from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input import numpy as np import warnings -from policyengine_core.periods import instant +from policyengine_core.periods import instant, period as period_ # Universal Credit Act 2025: the first tax year of the rebalancing (2026-27, @@ -214,6 +214,54 @@ def _new_claimant_health_element_status( return status +def _lcwra_amount_is_overridden(sim, year: int) -> bool: + """An explicit LCWRA amount keeps precedence over the inactive path.""" + from policyengine_uk.utils.dated_reforms import legacy_override_bounds + from policyengine_uk.tax_benefit_system import ( + reform_parameter_defines, + parameter_reads_calendar_years, + ) + + system = sim.tax_benefit_system + overrides = list(getattr(system, "legacy_overrides", ())) + # Single-day constructor overrides run after this modifier. Respect + # their requested window before caching an inactive award. + for dated in getattr(sim, "_building_dated_reforms", None) or (): + _, pending = dated.read( + reform_parameter_defines(), + calendar_years=parameter_reads_calendar_years(), + ) + overrides.extend(pending) + statutory_sample = instant(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + annual_sample = instant(f"{year}-01-01") + return any( + change.path == LCWRA_AMOUNT and change.start <= statutory_sample <= change.stop + for change in getattr(system, "dated_assignments", ()) + ) or any( + change.path == LCWRA_AMOUNT and start <= annual_sample <= stop + for change in overrides + for start, stop in [legacy_override_bounds(change, system.parameters)] + ) + + +def _inactive_health_element_monthly(sim, year: int) -> float: + """The pre-Act amount, uprated independently from the last pre-Act year. + + The Act's new-claimant anchor remains on the underlying parameter. A + year with rebalancing switched off instead follows the ordinary benefit + index from the 2025-26 amount; it never reads a prior paid award. + """ + parameters = sim.tax_benefit_system.parameters + base_year = FIRST_REBALANCING_YEAR - 1 + before = parameters(str(base_year)).gov + current = parameters(str(year)).gov + return ( + before.dwp.universal_credit.elements.disabled.amount + * current.benefit_uprating_cpi + / before.benefit_uprating_cpi + ) + + def add_universal_credit_reform(sim: Microsimulation): rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing @@ -227,6 +275,26 @@ def add_universal_credit_reform(sim: Microsimulation): is_data = built_from_data(sim) for year in REBALANCING_YEARS: if not rebalancing.active(year): + population = sim.get_variable_population("uc_LCWRA_element") + if ( + supplied_input(population, "uc_LCWRA_element", period_(year)) + is not None + ): + continue + # Re-read the underlying consumer, not an award this modifier + # stored on an earlier invocation or before a parameter edit. + sim.delete_arrays("uc_LCWRA_element", year) + if _lcwra_amount_is_overridden(sim, year): + continue + current = np.array(sim.calculate("uc_LCWRA_element", year)) + amount = sim.tax_benefit_system.parameters( + str(year) + ).gov.dwp.universal_credit.elements.disabled.amount + if amount != 0: + # Scale the existing consumer's result, retaining its claimant + # eligibility/counting rules and zero awards. + current *= _inactive_health_element_monthly(sim, year) / amount + set_model_value(sim, "uc_LCWRA_element", year, current) continue is_post_2025_claimant = _new_claimant_health_element_status( sim, year, uc_seed < new_claimant_share(year), is_data diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 5340e91e53..47d9757dd6 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -408,3 +408,169 @@ def test_a_branch_can_state_its_own_status(): _new_claimant_rate(sim, 2026) ) assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(parent) + + +# The inactive health-component counterfactual starts from pre-Act 2025 law, +# independently of the new/protected claimant split (issue #2283). +def _inactive_lcwra_expected(sim, year): + parameters = sim.tax_benefit_system.parameters + + def index(y): + return float(parameters(str(y)).gov.benefit_uprating_cpi) + + return LCWRA_2025 * index(year) / index(2025) * 12 + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_inactive_health_element_restores_pre_act_uprated_amount(year): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", year)[0] + # Nonzero verifies that this is the explicit-year claimant, not the + # nondiagnostic scalar-input/default-2025 situation from preflight. + assert actual > 0 + assert actual == pytest.approx(_inactive_lcwra_expected(sim, year), rel=0, abs=1e-3) + + +@pytest.mark.parametrize("new_claimant", [False, True]) +def test_inactive_health_amount_is_independent_of_claimant_cohort(new_claimant): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_receives_new_claimant_health_element"] = { + year: new_claimant for year in YEARS + } + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + _inactive_lcwra_expected(sim, 2026), rel=0, abs=1e-3 + ) + + +def test_mixed_toggle_uses_independent_inactive_years_and_retains_active_path(): + baseline = Simulation(situation=_uc_claimant(30)) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": { + "2026": False, + "2027": True, + "2028": False, + }, + }, + applied_before_data_load=True, + ), + ) + for year in [2026, 2028]: + assert sim.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + _inactive_lcwra_expected(sim, year), rel=0, abs=1e-3 + ) + assert sim.calculate("uc_LCWRA_element", 2027)[0] == pytest.approx( + baseline.calculate("uc_LCWRA_element", 2027)[0], rel=0, abs=1e-3 + ) + + +@pytest.mark.parametrize("route", ["scenario", "legacy_reform"]) +def test_inactive_health_element_keeps_explicit_parameter_amount(route): + changes = { + "gov.dwp.universal_credit.rebalancing.active": False, + "gov.dwp.universal_credit.elements.disabled.amount": {"2026": 300}, + } + kwargs = { + "scenario": Scenario(parameter_changes=changes, applied_before_data_load=True) + } + if route == "legacy_reform": + kwargs = { + "reform": { + "gov.dwp.universal_credit.rebalancing.active": {"2026": False}, + "gov.dwp.universal_credit.elements.disabled.amount": { + "2026-01-01": 300 + }, + } + } + sim = Simulation(situation=_uc_claimant(30), **kwargs) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +def test_inactive_health_element_keeps_directly_supplied_award(): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_LCWRA_element"] = {"2026": 7_200} + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == 7_200 + + +def test_inactive_health_element_reaches_final_uc_without_forced_intermediates(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # No earnings, other benefits, housing or children: the standard allowance + # plus the independent inactive health amount is the whole UC award. + expected = STANDARD_ALLOWANCE_2026["SINGLE_OLD"] * 12 + _inactive_lcwra_expected( + sim, 2026 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_modifier_is_idempotent(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + expected = _inactive_lcwra_expected(sim, 2026) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_amount_reform_on_a_built_simulation_keeps_precedence(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) From e38883e5d6221b539c8017fbc6e09bff8a816976 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 00:58:25 -0400 Subject: [PATCH 074/141] Count IIDB, SDA and IB for the Council Tax Reduction applicant and partner The benefit unit's claimant-or-partner flag picks the wrong people where a household head applies alone: it counted another couple's IIDB against the head's claim and left out her own. Use is_council_tax_reduction_applicant_or_partner, which is the head alone in that case and the claimant and partner otherwise. Two YAML cases cover it. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- ...cil_tax_reduction_prescribed_benefits.yaml | 80 +++++++++++++++++++ ...council_tax_reduction_applicable_income.py | 9 ++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index 94b91ccdbd..458720e4e5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -439,6 +439,86 @@ # pensioner schemes also exclude Child Benefit. council_tax_reduction_applicable_income: 241.30 * 52 +- name: Where a household head applies alone, another couple's IIDB in her benefit unit does not count + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + mother: + age: 45 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + father: + age: 46 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The grandmother is the applicant and has no partner here (SI 2012/2885 + # Sch 1 para 11); the parents are not her partners. + council_tax_reduction_head_applies_alone: true + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_applicable_income: 0 + +- name: Where a household head applies alone, her own IIDB counts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + iidb_reported: 116.95 * 52 + mother: + age: 45 + is_parent: true + is_household_head: false + father: + age: 46 + is_parent: true + is_household_head: false + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_applicable_income: 116.95 * 52 + - name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 848f414049..25405fef5b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -123,11 +123,12 @@ def formula(benunit, period, parameters): # 105), by name or as "attendance allowance" (Sch 8 and Sch 9 para # 14), but iidb is the whole reported payment and cannot separate # them. Only the applicant's and partner's count (Sch 1 para 11; SSI - # 2021/249 reg 36(1)), so these leave out the dependants that the - # wider mask above still reaches. + # 2021/249 reg 36(1)): the Council Tax Reduction applicant and partner, + # which is the household head alone where the head applies alone, and + # never the dependants that the wider mask above still reaches. applicant_and_partner_benefits = ["iidb", "sda", "incapacity_benefit"] - applicant_and_partner = ( - person("is_claimant_or_partner", period) | applicant_counted_as_young_person + applicant_and_partner = person( + "is_council_tax_reduction_applicant_or_partner", period ) # War disablement pensions, war widow's or widower's pensions and # Armed Forces Compensation Scheme payments (afcs, war_widows_pension). From 33dabfdddde31f9ff0e017452f5755039a5b3e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:10:47 +0100 Subject: [PATCH 075/141] Fix late LCWRA amount reform cache coherence --- changelog.d/2283.fixed.md | 2 +- docs/book/policy/uc-rebalancing.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 43 +++++++++++++------- policyengine_uk/tests/test_uc_rebalancing.py | 29 +++++++++++++ 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md index 9632996671..549abc91d9 100644 --- a/changelog.d/2283.fixed.md +++ b/changelog.d/2283.fixed.md @@ -1 +1 @@ -When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit amount and award inputs. +When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 190889794e..f98ea2ad6d 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -92,7 +92,7 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` -For the health component, an inactive year uses the 2025-26 monthly LCWRA amount (£423.27), uprated by the model's ordinary benefit uprating index from 2025-26 to that year. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that pre-Act amount, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. +For the health component, an inactive year uses the 2025-26 monthly LCWRA amount (£423.27), uprated by the model's ordinary benefit uprating index from 2025-26 to that year. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that pre-Act amount, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 55e6209d90..48c397403a 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,4 +1,4 @@ -from policyengine_uk.model_api import Scenario +from policyengine_uk.model_api import Scenario, Variable from policyengine_uk import Microsimulation from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR @@ -262,7 +262,34 @@ def _inactive_health_element_monthly(sim, year: int) -> float: ) +def _add_inactive_health_element_formula(sim): + """Keep the counterfactual as calculated output, invalidated by reforms.""" + system = sim.tax_benefit_system + original = system.get_variable("uc_LCWRA_element") + if getattr(original, "_inactive_health_element_formula", False): + return + + class uc_LCWRA_element(Variable): + def formula(benunit, period, parameters): + current = original.get_formula(period)(benunit, period, parameters) + year = period.start.year + if year not in REBALANCING_YEARS: + return current + simulation = benunit.simulation + p = parameters(period).gov.dwp.universal_credit + if p.rebalancing.active or _lcwra_amount_is_overridden(simulation, year): + return current + amount = p.elements.disabled.amount + if amount == 0: + return current + return current * _inactive_health_element_monthly(simulation, year) / amount + + variable = system.update_variable(uc_LCWRA_element) + variable._inactive_health_element_formula = True + + def add_universal_credit_reform(sim: Microsimulation): + _add_inactive_health_element_formula(sim) rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing generator = np.random.default_rng(43) @@ -281,20 +308,8 @@ def add_universal_credit_reform(sim: Microsimulation): is not None ): continue - # Re-read the underlying consumer, not an award this modifier - # stored on an earlier invocation or before a parameter edit. + # Recalculate through the formula, preserving user-supplied awards. sim.delete_arrays("uc_LCWRA_element", year) - if _lcwra_amount_is_overridden(sim, year): - continue - current = np.array(sim.calculate("uc_LCWRA_element", year)) - amount = sim.tax_benefit_system.parameters( - str(year) - ).gov.dwp.universal_credit.elements.disabled.amount - if amount != 0: - # Scale the existing consumer's result, retaining its claimant - # eligibility/counting rules and zero awards. - current *= _inactive_health_element_monthly(sim, year) / amount - set_model_value(sim, "uc_LCWRA_element", year, current) continue is_post_2025_claimant = _new_claimant_health_element_status( sim, year, uc_seed < new_claimant_share(year), is_data diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 47d9757dd6..be1c70c57e 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -574,3 +574,32 @@ def test_inactive_health_amount_reform_on_a_built_simulation_keeps_precedence(): ).apply(sim) uc_reform.add_universal_credit_reform(sim) assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +@pytest.mark.parametrize("repeat_modifier", [False, True]) +def test_inactive_cached_awards_follow_public_late_amount_reform(repeat_modifier): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # Prime both caches before using the public parameter-change lifecycle. + assert sim.calculate("uc_LCWRA_element", 2026)[0] > 3_600 + assert sim.calculate("universal_credit", 2026)[0] > 8_698.80 + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + if repeat_modifier: + uc_reform.add_universal_credit_reform(sim) + # The 2026 single-25+ standard allowance is £424.90/month (SI 2026/113), + # plus the explicitly reformed £300/month LCWRA amount, with no reductions. + assert [ + sim.calculate("uc_LCWRA_element", 2026)[0], + sim.calculate("universal_credit", 2026)[0], + ] == pytest.approx([3_600, 8_698.80], rel=0, abs=0.01) From 818e96662607bfccf3c6516f3161808fb322d0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:03:11 +0100 Subject: [PATCH 076/141] Fix statutory-pay pension relief fixture after main integration --- .../reliefs/adjusted_total_income_pension_relief.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml index 8760447c9f..6c9ad649c2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml @@ -277,7 +277,7 @@ region: LONDON output: adjusted_total_income: 250000 - pension_contributions_relief: 3600 + pension_contributions_relief: 4000 - name: The basic amount remains reformable for actual RAS contributions period: 2026 absolute_error_margin: 0.01 From 1ae527d7ce5c1ed1ae2b415564f352416d2b112f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 02:53:23 -0400 Subject: [PATCH 077/141] Declare claimant and dependant roles in the CTR prescribed-benefit properties CI found a draw where a dependant of 17 was under 16 years younger than a claimant of 32, so the model's presumption read them as a partner and their IIDB counted. The generated situations now set is_claimant_or_partner for every member, and that draw is pinned as an explicit example. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- ...eduction_prescribed_benefits_properties.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py index 037f5d96f5..6e1541fa8c 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -202,6 +202,20 @@ def working_age_family(draw): ) +# A working-age lone adult in Westminster, close in age to a dependant. +YOUNG_PARENT = dict( + ages=[32], + state_pensions=[0.0], + earnings=[12_000.0], + benefits=[dict(iidb=0.0, incapacity_benefit=0.0, sda=False)], + property_income=0.0, + area="WESTMINSTER", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) + + def area_of(fam): if fam["ages"][0] >= 67: return PENSION_AGE_AREAS[fam["area"]] @@ -223,6 +237,12 @@ def situation(variants): b = fam["benefits"][j] person = { "age": {YEAR: age}, + # The generated adults are the claimant and partner, and any + # dependant is not: say so for everyone, so the model's + # presumption (a member under 20 and 16 or more years younger + # than the claimant is their child) cannot read a dependant + # close in age to the claimant as a partner. + "is_claimant_or_partner": {YEAR: True}, "state_pension": {YEAR: fam["state_pensions"][j]}, "employment_income": {YEAR: fam["earnings"][j]}, "private_pension_income": {YEAR: private_pension * (j == 0)}, @@ -238,7 +258,10 @@ def situation(variants): if fam.get("dependant_age") is not None: j = len(fam["ages"]) name = f"p{i}_{j}" - people[name] = {"age": {YEAR: fam["dependant_age"]}} + people[name] = { + "age": {YEAR: fam["dependant_age"]}, + "is_claimant_or_partner": {YEAR: False}, + } for variable, value in extra.get(j, {}).items(): people[name][variable] = {YEAR: value} names.append(name) @@ -421,6 +444,9 @@ def test_war_pensions_never_count(data): st.integers(16, 19), ) @example([GENERAL_RULES_PENSIONER, KINGSTON_COUPLE], "iidb", 17) +# Found by CI: a dependant of 17 is under 16 years younger than a claimant of +# 32, so without the declared roles the model read them as a partner. +@example([YOUNG_PARENT], "iidb", 17) def test_dependants_benefits_never_count(families, change, dependant_age): families = [{**fam, "dependant_age": dependant_age} for fam in families] variants = [] From 0163a613dd117622c4545faa3e6ea8d63d9c31db Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 20 Oct 2026 09:23:04 +0200 Subject: [PATCH 078/141] Count SSP and SMP in relevant UK earnings in the pension docs #2464 added employer-paid Statutory Sick Pay and Statutory Maternity Pay (employment_benefits) to relevant UK earnings for pension tax relief, in line with FA 2004 s. 189(2) and HMRC PTM044100. Update the pension tax relief page and the STBM comparison table to match, and note that Statutory Paternity Pay still waits on #2368. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/hmrc/pension-tax-relief.md | 22 +++++++++++++------ .../gov/hmrc/pensions-comparison-stbm.md | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md index 4cb4b40702..925c9aa865 100644 --- a/docs/book/programs/gov/hmrc/pension-tax-relief.md +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -15,9 +15,17 @@ with the limit in `utils/pensions.py`) reduces the person's taxable income by their pension contributions, up to the annual limit for relief in [Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190): -- the person's relevant UK earnings, which the model takes as employment - income plus self-employment profit (a trading loss counts as zero, and - does not reduce employment income); +- the person's relevant UK earnings + ([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)), + which the model takes as employment income, plus employer-paid Statutory + Sick Pay and Statutory Maternity Pay (`employment_benefits`, listed as + relevant UK earnings in HMRC's + [PTM044100](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100)), + plus self-employment profit (a trading loss counts as zero, and does not + reduce employment earnings). Statutory Paternity Pay is not yet counted; + open pull request + [#2368](https://github.com/PolicyEngine/policyengine-uk/pull/2368) would + add it to taxable employment pay; - raised towards `gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount` (£3,600) where earnings are lower, but only by relief-at-source contributions ([s. 191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191)). @@ -35,10 +43,10 @@ gross contribution is £2,880 paid by the contributor plus £720 (20%) that the pension provider claims from HMRC and adds to the pension ([Finance Act 2004 s. 192](https://www.legislation.gov.uk/ukpga/2004/12/section/192)). PolicyEngine does not represent the £720 claim as a payment, and with no -taxable income the deduction saves no income tax. Two open pull requests -would change this section once merged: -[#2464](https://github.com/PolicyEngine/policyengine-uk/pull/2464) would add -Statutory Sick Pay and Statutory Maternity Pay to relevant earnings, and +taxable income the deduction saves no income tax. Statutory Sick Pay and +Statutory Maternity Pay were added to relevant earnings by +[#2464](https://github.com/PolicyEngine/policyengine-uk/pull/2464), merged on +19 October 2026. One open pull request would change this section once merged: [#2488](https://github.com/PolicyEngine/policyengine-uk/pull/2488) would apply the same relief-at-source-only rule to the pension uplift in adjusted total income. diff --git a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md index 39f18ab016..be51378fe7 100644 --- a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md +++ b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md @@ -23,7 +23,7 @@ more detail on the PolicyEngine side. | How relief is given | Reduces taxable income by the contribution (`pension_contributions_relief`) | Treats contributions as paid net of basic rate: grosses them up by 1/(1 − basic rate) and widens every rate band above the starting band by the gross amount, as under relief at source | | Contributions that get relief | The person's own contributions | Employee contributions, additional voluntary contributions **and employer contributions** | | Basic-rate relief added to the pot | Not shown separately | Recorded separately (`pension_relief_at_source`) | -| Earnings limit | Relief capped at employment plus self-employment income | No earnings cap; £3,600 limit when total income is below it | +| Earnings limit | Relief capped at relevant UK earnings: employment income, employer-paid Statutory Sick Pay and Statutory Maternity Pay, and self-employment profit | No earnings cap; £3,600 limit when total income is below it | | Annual allowance | £60,000, tapered on adjusted net income above £260,000 (`pension_annual_allowance`) | Tapered on total income above a threshold-income parameter | | Contributions above the allowance | Charged at the marginal rate (`personal_pension_contributions_tax`) | Relief stops at the allowance; no separate charge | | Employer contributions in the data | Read from the data (`employer_pension_contributions`) | Imputed when missing from the FRS, using the ONS ASHE employer-contribution bands (`src/Pensions.jl`) | From 6659ebfec11c2229378253d88c2223f1231ac93d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 20 Oct 2026 09:31:49 +0200 Subject: [PATCH 079/141] Update pension docs for the statutory annual allowance taper and carry-forward The STBM comparison said the taper uses adjusted net income with no threshold-income test, that carry-forward is not modelled and that adjusted net income ignores pension relief. Main now applies both s. 228ZA tests, takes unused allowance (s. 228A) as an input defaulting to nil, and deducts relief in adjusted net income. State the remaining limits (no MPAA, cash-proxy pension input) and re-run the examples on 2.140.0. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/hmrc/pension-tax-relief.md | 19 +++++-- .../gov/hmrc/pensions-comparison-stbm.md | 53 ++++++++++++------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md index 925c9aa865..1762ca37f6 100644 --- a/docs/book/programs/gov/hmrc/pension-tax-relief.md +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -5,7 +5,7 @@ pension contributions and addresses the confusion reported in [#704](https://github.com/PolicyEngine/policyengine-uk/issues/704) about whether higher-rate taxpayers receive the right amount of relief. #704 is still open (October 2026); the figures below were re-run -on policyengine-uk 2.135.6. +on policyengine-uk 2.140.0. ## What the model does @@ -62,7 +62,15 @@ rounded down to whole pounds, to a minimum of £10,000 ([Finance Act 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA); parameters under `gov.hmrc.income_tax.allowances.annual_allowance`). See `docs/engineering/pension-annual-allowance.md` for the income conventions. -The money purchase annual allowance is not modelled. +Unused allowance carried forward from the previous three tax years +([s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A)) is +the input `unused_pension_annual_allowance`, which is added to the allowance +when the charge is worked out. It defaults to nil, because survey data does +not record past pension input amounts, and the model does not derive it from +earlier years. The money purchase annual allowance, the £10,000 limit on +money purchase savings after pensions are first flexibly accessed +([s. 227ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/227ZA)), +is not modelled. Contributions **above** the annual allowance can still be relieved and separately attract the annual allowance charge, @@ -76,7 +84,10 @@ contributions made before age 75, employer contributions and salary sacrifice ([Finance Act 2004 s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233)). For example, an employee on £100,000 who pays £70,000 into a personal pension in 2025-26 gets £70,000 of relief and a £2,000 charge on the £10,000 excess, -which falls in the basic rate band. +which falls in the basic rate band. With £10,000 of unused allowance +brought forward, there is no charge. By default the pension input amount is +cash contributions: defined-benefit accrual counts only if supplied through +`pension_contributions_for_annual_allowance`. Relieved contributions also reduce `adjusted_net_income`, so they restore personal allowance lost above £100,000, as in law @@ -94,7 +105,7 @@ rate, plus any personal allowance restored. The four scenarios reported in #704 (all 2025-26, all self-employed, single person aged 40, no other income, contributions entered as `personal_pension_contributions`). The "model" column was re-run in -October 2026 on policyengine-uk 2.135.6: +October 2026 on policyengine-uk 2.140.0: | ID | Self-employed income | Pension contribution | Expected income-tax change | What the model returns | Why | |-------|---------------------|----------------------|-----------------------------|------------------------|-----| diff --git a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md index be51378fe7..67ce942385 100644 --- a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md +++ b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md @@ -24,10 +24,10 @@ more detail on the PolicyEngine side. | Contributions that get relief | The person's own contributions | Employee contributions, additional voluntary contributions **and employer contributions** | | Basic-rate relief added to the pot | Not shown separately | Recorded separately (`pension_relief_at_source`) | | Earnings limit | Relief capped at relevant UK earnings: employment income, employer-paid Statutory Sick Pay and Statutory Maternity Pay, and self-employment profit | No earnings cap; £3,600 limit when total income is below it | -| Annual allowance | £60,000, tapered on adjusted net income above £260,000 (`pension_annual_allowance`) | Tapered on total income above a threshold-income parameter | -| Contributions above the allowance | Charged at the marginal rate (`personal_pension_contributions_tax`) | Relief stops at the allowance; no separate charge | +| Annual allowance | £60,000 (`pension_annual_allowance`), tapered only when adjusted income is above £260,000 and threshold income is above £200,000, as in FA 2004 s. 228ZA | Tapered on total income above a threshold-income parameter | +| Contributions above the allowance | Annual allowance charge on the excess, taxed as the top slice of income (`personal_pension_contributions_tax`) | Relief stops at the allowance; no separate charge | | Employer contributions in the data | Read from the data (`employer_pension_contributions`) | Imputed when missing from the FRS, using the ONS ASHE employer-contribution bands (`src/Pensions.jl`) | -| Carry-forward of unused allowance | Not modelled | Not modelled | +| Carry-forward of unused allowance | Input `unused_pension_annual_allowance` (FA 2004 s. 228A) adds to the allowance; defaults to nil, as the survey data has no past pension input amounts | Not modelled | ## Where the two models differ @@ -38,16 +38,15 @@ methods give the same total relief. They can differ when the person pays little or no income tax: STBM still records the basic-rate addition to the pot, which PolicyEngine UK does not show. -**Shared gap: the personal allowance taper.** In law, gross pension -contributions reduce adjusted net income +**The personal allowance taper.** In law, gross pension contributions +reduce adjusted net income ([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)), so contributing can restore personal allowance lost above £100,000. -Neither model does this. In PolicyEngine UK, `adjusted_net_income` adds up -the taxable income components without deducting `pension_contributions_relief`: -an employee on £110,000 has adjusted net income of £110,000 and a personal -allowance of £7,570 in 2025-26 whether or not they contribute £10,000. -STBM widens the bands but leaves the taper income unchanged, and its code -flags this as unchecked. +PolicyEngine UK does this: `adjusted_net_income` deducts +`pension_contributions_relief`, so an employee on £110,000 has a personal +allowance of £7,570 in 2025-26, and the full £12,570 if they pay £10,000 +gross into a personal pension. STBM widens the bands but leaves the taper +income unchanged, and its code flags this as unchecked. ### 2. Employer contributions @@ -59,14 +58,29 @@ employee's bands further than the law does. ### 3. Annual allowance taper -The legal taper uses "adjusted income", which adds employer contributions, -and applies only when "threshold income" is also above £200,000. - -- PolicyEngine UK tapers on adjusted net income, with no threshold-income - test. +The legal taper +([FA 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA)) +uses "adjusted income", which adds employer contributions, and applies only +when "threshold income" is also above £200,000 (from 2023-24). + +- PolicyEngine UK applies both tests + (`pension_annual_allowance_adjusted_income` and + `pension_annual_allowance_threshold_income`). An employee on £300,000 + who pays £100,000 gross into a personal pension in 2025-26 has adjusted + income of £300,000 but threshold income of £200,000, so keeps the full + £60,000 allowance. Its limits: by default the pension input amount is + cash contributions, so defined-benefit accrual is counted only if supplied + as an input, and all salary sacrifice is treated as made on or after + 9 July 2015. - STBM tapers on total income above a threshold parameter. -Neither follows the statute exactly. Both affect only very high earners. +Both affect only very high earners. + +PolicyEngine UK does not model the money purchase annual allowance, the +£10,000 limit on money purchase savings after pensions are first flexibly +accessed +([FA 2004 s. 227ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/227ZA)). +It always uses the default chargeable amount. ### 4. Lifetime and lump sum allowances @@ -77,7 +91,9 @@ are drawn, not to contributions. ## What neither model does -- Carry-forward of unused annual allowance from the previous three years. +- Working out carry-forward of unused annual allowance from the previous + three years. PolicyEngine UK accepts the result as an input but does not + derive it from past years. - Behavioural responses: contributions are held fixed when tax rates change. - Decumulation: crystallisation, the tax-free lump sum, and annuity versus drawdown choices. @@ -87,6 +103,7 @@ are drawn, not to contributions. - STBM source: [`IncomeTaxCalculations.jl`][stbm-it] and [`Pensions.jl`][stbm-pen] at `f534ca1`. - [STBM blog: pension contributions][stbm-blog]. - PolicyEngine UK: [pension tax relief](./pension-tax-relief.md); variables [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py), [`pension_annual_allowance`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). +- [Finance Act 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA) (taper) and [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) (carry-forward). - HMRC, [Pensions Tax Manual: annual allowance taper](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm057100). - Issue: [#672](https://github.com/PolicyEngine/policyengine-uk/issues/672). From 2ae29597ddb3edec35127cbeb8797032dc1bd9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:37:40 +0100 Subject: [PATCH 080/141] Preserve LCWRA wrapper provenance for benefit-cap exemptions --- changelog.d/2283.fixed.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 15 +++ ...nefit_cap_specified_benefits_properties.py | 91 +++++++++++++++++++ ...uc_benefit_cap_exempt_specified_benefit.py | 4 +- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md index 549abc91d9..f343249681 100644 --- a/changelog.d/2283.fixed.md +++ b/changelog.d/2283.fixed.md @@ -1 +1 @@ -When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. +When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 48c397403a..61d0e4862b 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -5,6 +5,7 @@ from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input import numpy as np import warnings +from weakref import WeakKeyDictionary from policyengine_core.periods import instant, period as period_ @@ -262,6 +263,19 @@ def _inactive_health_element_monthly(sim, year: int) -> float: ) +# Only functions registered here are this internal amount wrapper. External +# wrappers, even ones copying function attributes, keep their own provenance. +_INACTIVE_HEALTH_ELEMENT_ORIGINS = WeakKeyDictionary() + + +def lcwra_formula_origin(formula, period): + """Resolve the formula delegated by our internal LCWRA amount wrapper.""" + if formula is None: + return None + original = _INACTIVE_HEALTH_ELEMENT_ORIGINS.get(formula) + return original.get_formula(period) if original is not None else formula + + def _add_inactive_health_element_formula(sim): """Keep the counterfactual as calculated output, invalidated by reforms.""" system = sim.tax_benefit_system @@ -284,6 +298,7 @@ def formula(benunit, period, parameters): return current return current * _inactive_health_element_monthly(simulation, year) / amount + _INACTIVE_HEALTH_ELEMENT_ORIGINS[uc_LCWRA_element.formula] = original variable = system.update_variable(uc_LCWRA_element) variable._inactive_health_element_formula = True diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 7d428435e8..9648177f44 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -940,3 +940,94 @@ def test_an_element_is_entered_exactly_when_its_last_store_was_supplied( assert is_entered(simulation, year) == entered, operations for clone_source, was_entered in earlier: assert is_entered(clone_source, year) == was_entered, operations + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("holder_role", ["claimant", "other"]) +def test_internal_lcwra_wrapper_keeps_the_model_claimant_filter( + year, active, holder_role +): + """An internal amount wrapper does not turn nonclaimant circumstances into an award.""" + from policyengine_uk.model_api import Scenario + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + claimant, other = blank("claimant", 40), blank("other", 30) + (claimant if holder_role == "claimant" else other)[ + "uc_limited_capability_for_WRA" + ] = True + sim = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})], year), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", year)[0] > 0 + assert not is_entered(sim, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("amount", [0, 5_000]) +def test_internal_lcwra_wrapper_keeps_explicit_positive_and_nil_entries(active, amount): + from policyengine_uk.model_api import Scenario + + sim = Simulation( + situation=element_situation(2026, amount), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert is_entered(sim, 2026) + assert bool(sim.calculate(UC_SPECIFIED, 2026)[0]) == (amount > 0) + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("amount", [0, 6_000]) +@pytest.mark.parametrize("when", ["constructor", "after_internal_wrapper"]) +def test_internal_lcwra_wrapper_preserves_real_external_formula_origin( + year, amount, when +): + from policyengine_uk.model_api import Scenario + from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.is_uc_benefit_cap_exempt_specified_benefit import ( + calculated_by, + uc_LCWRA_element as model_lcwra_element, + ) + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + reform = element_reform("uc_LCWRA_element", amount) + sim = Simulation( + situation=element_situation(year), + reform=reform if when == "constructor" else None, + ) + if when == "after_internal_wrapper": + Scenario.from_reform(reform).apply(sim) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index 531c812ba6..ddbd0abac2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -52,8 +52,10 @@ def calculated_by(population, variable_name, period, model_variable): formula (``model_variable``'s), rather than one a reform put in its place. The tax-benefit system loads each variable file as its own module, so the formula is matched by where its code is, not by identity.""" + from policyengine_uk.scenarios.uc_reform import lcwra_formula_origin + variable = population.simulation.tax_benefit_system.get_variable(variable_name) - formula = variable.get_formula(period) + formula = lcwra_formula_origin(variable.get_formula(period), period) code = getattr(formula, "__code__", None) model = model_variable.formula.__code__ return code is not None and ( From 3c7f6bcb7f4dfb22190b8b4cf700394a5ff1fd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:36:00 +0100 Subject: [PATCH 081/141] Correct the salary sacrifice NI earnings note --- policyengine_uk/data/fiscal_partition.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index cb5d415d0d..169920271d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -481,7 +481,7 @@ exclusions: - {variable: ni_class_3, kind: component, head: nics_individual} - {variable: ni_class_4, kind: component, head: nics_individual} - {variable: ni_class_1_employer, kind: component, head: nics_employer} - - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to employment_income, so Class 1 already charges it."} + - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to ni_class_1_income, so Class 1 already charges it."} - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} - {variable: tax, kind: composite, spans: [income_tax, nics_individual]} - {variable: benunit_tax, kind: composite, spans: [income_tax, nics_individual]} From f12016368d0552886477dd1f20b647fac4681fb0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 05:19:23 -0400 Subject: [PATCH 082/141] Pin the CI counterexample at the dependant age CI drew (19) Review of 1ae527d7c noted the failing CI draw had a dependant of 19; at 17 the model already reads them as a child. Correct the example and comment. Part of #2510. Co-Authored-By: Claude Opus 5.5 --- ...st_council_tax_reduction_prescribed_benefits_properties.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py index 6e1541fa8c..3bfc7883ef 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -444,9 +444,9 @@ def test_war_pensions_never_count(data): st.integers(16, 19), ) @example([GENERAL_RULES_PENSIONER, KINGSTON_COUPLE], "iidb", 17) -# Found by CI: a dependant of 17 is under 16 years younger than a claimant of +# Found by CI: a dependant of 19 is under 16 years younger than a claimant of # 32, so without the declared roles the model read them as a partner. -@example([YOUNG_PARENT], "iidb", 17) +@example([YOUNG_PARENT], "iidb", 19) def test_dependants_benefits_never_count(families, change, dependant_age): families = [{**fam, "dependant_age": dependant_age} for fam in families] variants = [] From 9eb9aafff1923f59446a4ee7a8029d44b4e002ab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:06:50 -0400 Subject: [PATCH 083/141] Preserve late explicit inputs in Pension Credit tax counterfactuals --- .../tests/test_pension_credit_late_inputs.py | 81 +++++++++++++++++++ .../pension_credit_earnings_income_tax.py | 14 +--- 2 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_late_inputs.py diff --git a/policyengine_uk/tests/test_pension_credit_late_inputs.py b/policyengine_uk/tests/test_pension_credit_late_inputs.py new file mode 100644 index 0000000000..390c351ee3 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_late_inputs.py @@ -0,0 +1,81 @@ +"""Explicit tax reliefs survive Pension Credit's earnings counterfactuals.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize("late", [False, True]) +def test_explicit_allowances_keep_couple_award_flat(late): + # Two AA recipients qualify for the £20/week combined disregard. + # The explicit £20,000 allowance leaves £148 pension tax after marriage + # allowance; £1,000 wages adds £200 tax and £800 net earnings, all ignored. + # PC income therefore stays £22,000 - £148 = £21,852, and PC £5,986.20. + situation = { + "people": { + "a": { + "age": {"2026": 70}, + "private_pension_income": {"2026": 22_000}, + "attendance_allowance": {"2026": 3_000}, + }, + "b": {"age": {"2026": 70}, "attendance_allowance": {"2026": 3_000}}, + }, + "benunits": {"b": {"members": ["a", "b"], "would_claim_pc": {"2026": True}}}, + "households": {"h": {"members": ["a", "b"]}}, + } + awards = [] + for wages, expected_tax, expected_disregard in [(0, 0, 0), (1_000, 200, 800)]: + data = deepcopy(situation) + data["people"]["a"]["employment_income"] = {"2026": wages} + if not late: + for name, allowance in [("a", 20_000), ("b", 12_570)]: + data["people"][name]["personal_allowance"] = {"2026": allowance} + sim = Simulation(situation=data) + if late: + sim.set_input("personal_allowance", 2026, [20_000, 12_570]) + assert sim.calculate("is_pension_credit_eligible", 2026)[0] + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(expected_tax) + assert sim.calculate("pension_credit_earnings_disregard", 2026)[ + 0 + ] == pytest.approx(expected_disregard) + assert sim.calculate("pension_credit_income", 2026)[0] == pytest.approx(21_852) + awards.append(sim.calculate("pension_credit", 2026)[0]) + np.testing.assert_array_equal( + sim.calculate("personal_allowance", 2026), [20_000, 12_570] + ) + assert sim.branches == {} + assert awards == pytest.approx([5_986.20, 5_986.20], abs=0.01) + + +def test_late_single_person_allowance_survives_counterfactual(): + # £18,000 pension is below the explicit £20,000 allowance. With £10,000 + # wages, £8,000 is taxed at 20%: the entire £1,600 is caused by employment. + sim = Simulation( + situation={ + "people": { + "a": { + "age": {"2026": 75}, + "private_pension_income": {"2026": 18_000}, + "employment_income": {"2026": 10_000}, + } + }, + "benunits": {"b": {"members": ["a"]}}, + "households": {"h": {"members": ["a"], "country": {"2026": "ENGLAND"}}}, + } + ) + sim.set_input("personal_allowance", 2026, [20_000]) + assert sim.calculate("income_tax", 2026)[0] == pytest.approx(1_600) + assert ( + sim.calculate("pension_credit_income_tax_without_employment_income", 2026)[0] + == 0 + ) + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(1_600) + np.testing.assert_array_equal(sim.calculate("personal_allowance", 2026), [20_000]) + assert sim.branches == {} diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py index 565315d514..cb30beb1d6 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -3,7 +3,7 @@ DECISION = ( "PolicyEngine does not model PAYE codes, so which part of a person's " "income tax is payable on their earnings is a methodology choice, put to " - "Max on PolicyEngine/policyengine-uk#2019; this provisional choice treats " + "Max on PolicyEngine/policyengine-uk#2019 and settled as option (a): treat " "earnings as the top slice of the person's income. " ) @@ -16,15 +16,9 @@ def income_tax_with_inputs_zeroed(simulation, period, variables): name += "_" branch = simulation.get_branch(name) try: - # A branch starts with the values already calculated, so drop them - # before replacing the inputs, as marginal_tax_rate does. - system_variables = simulation.tax_benefit_system.variables - for variable in system_variables: - if ( - variable not in simulation.input_variables - and not system_variables[variable].is_input_variable() - ): - branch.delete_arrays(variable) + # Preserve every explicit stored input, including formula overrides + # supplied through set_input after the simulation was constructed. + branch.drop_computed_arrays() people = branch.populations["person"] for variable in variables: branch.set_input(variable, period, np.zeros(people.count)) From 7736d5f70d409694cbb385c050b9c7fb064f86b0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:08:26 -0400 Subject: [PATCH 084/141] Fix Pension Credit notional Class 2 profit base --- .../test_pension_credit_notional_class2.py | 110 ++++++++++++++++++ ...nsion_credit_self_employment_deductions.py | 18 ++- 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_notional_class2.py diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py new file mode 100644 index 0000000000..28a9a9bb8e --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -0,0 +1,110 @@ +"""PC notional Class 2 uses CER chargeable income, before actual NI reliefs. + +CER 1996 reg. 14(2)(a), (3), at 6 April 2023: +https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06 +SSCBA 1992 s. 11(2), (4), at 6 April 2023: +https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06 +""" + +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize( + "profit, expected_deductions", + [(12_570, 0), (13_000, 304.10)], +) +def test_notional_class2_ignores_actual_trading_allowance(profit, expected_deductions): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {2023: 60}, + "self_employment_income": {2023: profit}, + "self_employment_gross_receipts": {2023: profit}, + } + } + } + ) + # Known receipts trigger the £1,000 trading allowance for actual NI. + assert simulation.calculate("ni_class_2", 2023)[0] == 0 + # CER chargeable income is profit before that allowance. At £13,000: + # tax = (£13,000 - £12,570) * 20% = £86; + # Class 4 = (£13,000 - £12,570) * 9% = £38.70; + # Class 2 = 52 * £3.45 = £179.40. Total = £304.10. + # At exactly £12,570 CER 14(2)(a) expressly excludes Class 2, and the + # personal allowance/lower profits limit make tax/Class 4 zero too. + assert simulation.calculate("pension_credit_self_employment_deductions", 2023)[ + 0 + ] == pytest.approx(expected_deductions, abs=0.01) + + +def test_other_trade_loss_keeps_protected_couple_notional_class2(): + results = [] + for loss in (0, 20_000): + simulation = Simulation( + situation={ + "people": { + "claimant": { + "age": {2023: 70}, + "attendance_allowance": {2023: 3_000}, + }, + "partner": { + "age": {2023: 60}, + "dla_sc_category": {2023: "MIDDLE"}, + "self_employment_income": {2023: 20_000}, + "trading_loss": {2023: loss}, + }, + }, + "benunits": { + "couple": { + "members": ["claimant", "partner"], + "has_mixed_age_couple_pension_credit_saving": {2023: True}, + "would_claim_pc": {2023: True}, + } + }, + "households": {"home": {"members": ["claimant", "partner"]}}, + } + ) + assert simulation.calculate("is_pension_credit_eligible", 2023)[0] + actual_class2 = simulation.calculate("ni_class_2", 2023)[1] + assert actual_class2 == pytest.approx(0 if loss else 179.40, abs=0.01) + # CER 13(12) prohibits another employment's loss reducing the + # £20,000 positive profit. Notional deductions are therefore + # (£20,000 - £12,570 PA - £1,260 marriage allowance) * 20% = + # £1,234 tax; plus £668.70 Class 4 and £179.40 Class 2 = £2,082.10. + deductions = simulation.calculate( + "pension_credit_self_employment_deductions", 2023 + )[1] + assert deductions == pytest.approx(2_082.10, abs=0.01) + results.append( + ( + deductions, + simulation.calculate("pension_credit_income", 2023)[0], + simulation.calculate("pension_credit", 2023)[0], + ) + ) + assert results[1] == pytest.approx(results[0], abs=0.01) + + +@pytest.mark.parametrize("year, age", [(2023, 70), (2024, 60)]) +def test_notional_class2_respects_pension_age_and_abolition(year, age): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {year: age}, + "self_employment_income": {year: 13_000}, + "self_employment_gross_receipts": {year: 13_000}, + } + } + } + ) + # SSCBA s.11(7)(b) excludes pension-age earners, while SI 2024/377 + # reg.8(7) omits CER 14(2)(a) from 6 April 2024. Neither case has a + # notional Class 2 deduction. Class 4 is £25.80 at 2024's 6% main rate. + expected = 86 if year == 2023 else 86 + 25.80 + assert simulation.calculate("pension_credit_self_employment_deductions", year)[ + 0 + ] == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 252d784f73..d8be62b50a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable class pension_credit_self_employment_deductions(Variable): @@ -12,7 +13,7 @@ class pension_credit_self_employment_deductions(Variable): "14), in place of those actually payable on the profit: a notional " "income tax at the basic rate (the Scottish basic rate for a Scottish " "taxpayer) on the profit less only the personal reliefs of ITA 2007 " - "Part 3 Chapters 2, 3 and 3A, whatever the person's other income; " + "Part 3 Chapters 2, 3 and 3A appropriate to their circumstances; " "Class 4 contributions at the main rate on the profit between the " "lower and upper profits limits, where Class 4 is payable at all; and " "the Class 2 contributions payable, until reg. 14(2)(a) was omitted on " @@ -24,6 +25,8 @@ class pension_credit_self_employment_deductions(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06", + "https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06", ) def formula(person, period, parameters): @@ -60,8 +63,19 @@ def formula(person, period, parameters): class_4.rates.main * min_(max_(0, profit - lower), upper - lower), 0, ) + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + # CER 14(2)(a), (3), before 6 April 2024: test this employment's + # chargeable income, not actual NI profits after trading allowance + # or other employment losses. SSCBA s.11 supplies the rate and + # age-liability rules. The compulsory rate is nil from 2024-25. + notional_class_2 = ( + person("ni_liable", period) + * class_2_liable(profit, class_2) + * class_2.flat_rate + * class_2_contribution_weeks(period.start.year) + ) return where( profit > 0, - notional_tax + notional_class_4 + person("ni_class_2", period), + notional_tax + notional_class_4 + notional_class_2, 0, ) From 9e23ce7dcb46e052aec439d77a4833c79b5209b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:10:29 -0400 Subject: [PATCH 085/141] Reuse Pension Credit earnings tax in UC housing branch --- changelog.d/pc-earnings-tax-reuse.changed.md | 1 + .../test_pension_credit_tax_branch_reuse.py | 95 +++++++++++++++++++ .../uc_non_dep_receives_pension_credit.py | 4 + 3 files changed, 100 insertions(+) create mode 100644 changelog.d/pc-earnings-tax-reuse.changed.md create mode 100644 policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py diff --git a/changelog.d/pc-earnings-tax-reuse.changed.md b/changelog.d/pc-earnings-tax-reuse.changed.md new file mode 100644 index 0000000000..601aa2a6d9 --- /dev/null +++ b/changelog.d/pc-earnings-tax-reuse.changed.md @@ -0,0 +1 @@ +- Reuse Pension Credit earnings tax calculations when checking Universal Credit housing contribution exemptions. diff --git a/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py new file mode 100644 index 0000000000..811daf2a9d --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py @@ -0,0 +1,95 @@ +"""Share earnings-tax counterfactuals across PC and UC housing calculations.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.tests.test_uc_non_dep_pension_credit import YEAR, mixed_families + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def earnings_families(): + situation = mixed_families() + for name, wages, profit in ( + ("tenant", 18_000, 1_000), + ("pc_older", 1_000, 2_000), + ("pc_younger", 0, 100), + ("working_non_dep", 10_000, 0), + ("renter_older", 1_000, 1_000), + ("renter_younger", 0, 0), + ): + situation["people"][name]["employment_income"] = {YEAR: wages} + situation["people"][name]["self_employment_income"] = {YEAR: profit} + return situation + + +def count_counterfactuals(monkeypatch, sim): + variable = sim.tax_benefit_system.variables[ + "pension_credit_income_tax_without_earnings" + ] + namespace = variable.get_formula(YEAR).__globals__ + original = namespace["income_tax_with_inputs_zeroed"] + calls = [] + + def counted(simulation, period, variables): + calls.append((simulation.branch_name, tuple(variables))) + return original(simulation, period, variables) + + monkeypatch.setitem(namespace, "income_tax_with_inputs_zeroed", counted) + return calls + + +@pytest.mark.parametrize("first", ["pension_credit", "household_net_income"]) +def test_full_calculation_reuses_two_earnings_tax_counterfactuals(monkeypatch, first): + sim = Simulation(situation=earnings_families()) + calls = count_counterfactuals(monkeypatch, sim) + sim.calculate(first, YEAR) + sim.calculate("household_net_income", YEAR) + sim.calculate("pension_credit", YEAR) + # Both employment and self-employment are present. The UC housing + # non-dependant check shares the two counterfactuals with ordinary PC. + assert calls == [ + ("default", ("employment_income",)), + ("default", ("employment_income", "self_employment_income")), + ] + assert sim.branches == {} + + +def test_earnings_tax_reuse_preserves_calculation_order_and_changed_inputs(): + pc_first = Simulation(situation=earnings_families()) + income_first = Simulation(situation=earnings_families()) + pc_first.calculate("pension_credit", YEAR) + income_first.calculate("household_net_income", YEAR) + outputs = ( + "household_net_income", + "pension_credit", + "pension_credit_income", + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + "uc_non_dep_receives_pension_credit", + "universal_credit", + ) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + income_first.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + wages = [18_000, 50_000, 0, 10_000, 1_000, 0] + pc_first.set_input("employment_income", YEAR, wages) + pc_first.drop_computed_arrays() + changed_situation = earnings_families() + changed_situation["people"]["pc_older"]["employment_income"] = {YEAR: 50_000} + fresh = Simulation(situation=changed_situation) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + fresh.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + assert not pc_first.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + assert pc_first.branches == {} diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py index 483b5c7cd1..84dd97e376 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py @@ -30,6 +30,10 @@ def formula(benunit, period, parameters): # their actual Pension Credit calculation while breaking the cycle # through the renter's UC entitlement and pension-age route. simulation = benunit.simulation + # Income tax is independent of the UC housing element. Calculate + # both earnings counterfactuals on the parent so this branch and + # ordinary Pension Credit share them whichever is requested first. + benunit.members("pension_credit_income_tax_without_earnings", period) name = "uc_non_dep_pension_credit" while name in simulation.branches or name == simulation.branch_name: name += "_" From a10ab0cfe94a089fc080d3519c2a98b37fb21113 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:12:49 -0400 Subject: [PATCH 086/141] Pin protected mixed-age couple Pension Credit award --- .../tests/test_pension_credit_notional_class2.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py index 28a9a9bb8e..9e177477da 100644 --- a/policyengine_uk/tests/test_pension_credit_notional_class2.py +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -85,6 +85,12 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): simulation.calculate("pension_credit", 2023)[0], ) ) + # SI 2023/316 art.30(2)(a), (d): the couple guarantee plus the higher + # severe-disability addition is (£306.85 + £152.80) * 52 = £23,901.80. + # https://www.legislation.gov.uk/uksi/2023/316/article/30/made + # PC income = £20,000 - £2,082.10 deductions - £1,040 disregard = + # £16,877.90; the award is £23,901.80 - £16,877.90 = £7,023.90. + assert results[0] == pytest.approx((2_082.10, 16_877.90, 7_023.90), abs=0.01) assert results[1] == pytest.approx(results[0], abs=0.01) From efcc491d2c98bf0b18576f523fd941a95164caec Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:14:49 -0400 Subject: [PATCH 087/141] fix: update Pension Credit gross earnings fixtures and held tax inputs --- .../child_amount_tax_credit_award.yaml | 23 ++++----- .../pension_credit_earnings_disregard.yaml | 49 ++++++++++-------- ...dits_pension_credit_passport_properties.py | 31 +++++++----- .../tests/test_trading_loss_properties.py | 50 ++++++++++++++++++- 4 files changed, 105 insertions(+), 48 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml index 2b8e58ff0e..9f9456d020 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml @@ -39,12 +39,11 @@ # credit (SPCA 2002 s.3(1)(a)). The 2018-19 and 2019-20 couples have no # income, so no savings credit either. The Housing Benefit cases' couple is # aged 80, so savings credit applies to it. The grandchild is 10 (11 in -# 2025-26), born before 6 April 2017. Pension Credit earnings are entered net -# of the SPC Regs Sch VI para 5 disregard (£10 a week for a couple), which -# the model does not apply. £10,000 of savings gives no Pension Credit tariff -# income, and £300 of interest uses up the £300 step-one disregard of tax -# credit income (SI 2002/2006 reg 3). No one pays income tax or National -# Insurance. +# 2025-26), born before 6 April 2017. Employment income is gross: the model +# applies the SPC Regs Sch VI para 5 disregard (£10 a week for a couple) +# once. £10,000 of savings gives no Pension Credit tariff income, and £300 +# of interest uses up the £300 step-one disregard of tax credit income +# (SI 2002/2006 reg 3). No one pays income tax or National Insurance. - name: A Pension Credit couple awarded CTC and WTC gets no child amount # Tax credit income: State Pension 8,000 + interest (300 - 300) + earnings @@ -83,7 +82,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 # 2,600 - 10 x 52 + # Gross wages are 2,600; Sch VI deducts 10 x 52, leaving 2,080. CTC_family_element: 545 households: household: @@ -125,7 +124,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages are 2,600; Sch VI deducts 520, leaving 2,080 once. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -166,7 +165,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # As in the first case, gross wages 2,600 become 2,080 after Sch VI. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -213,7 +212,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_980 # 3,500 - 10 x 52 + # Gross wages 3,500 become 2,980 after Sch VI, even with no PC paid. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -254,7 +253,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages 2,600 less one Sch VI disregard of 520 give 2,080. has_tax_credit_award: true households: household: @@ -407,7 +406,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Tax-credit closure does not alter gross wages 2,600 less Sch VI 520. standard_minimum_guarantee: 18_023.20 # 346.60 x 52 would_claim_uc_at_legacy_closure: false households: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index e5d98d6d72..f3f11f8954 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -424,15 +424,17 @@ household: members: [claimant] output: - # Income tax is 1,294 with the earnings and 1,086 without (next case): the - # earnings take 470 of non-savings income over the personal allowance, - # and take the personal allowance off 570 of savings interest. Tax on the - # earnings is 208, so net earnings are 832 and the 20 pound disregard of - # 1,040 is capped there. Pension Credit counts deemed income from capital - # rather than the interest, so its income is 12,000 + 1,040 - 1,294 - 832. - income_tax: 1_294 - pension_credit_employment_income_tax: 208 - pension_credit_earnings_disregard: 832 + # Non-savings tax: (12,000 + 1,040 - 12,570) x 20% = 94. + # Those taxable non-savings earnings reduce the savings starting-rate + # band to 5,000 - 470 = 4,530. Savings tax is + # (12,000 - 4,530 - 1,000 PSA) x 20% = 1,294, giving total tax 1,388. + # Without wages tax is 1,086 (next case), so employment tax is 302 and + # net earnings/disregard are 1,040 - 302 = 738, below the 1,040 cap. + # PC counts deemed capital income, not interest: 12,000 + 1,040 - 1,388 + # - 738 = 10,914. HMRC: /apply-tax-free-interest-on-savings/how-much-is-tax-free. + income_tax: 1_388 + pension_credit_employment_income_tax: 302 + pension_credit_earnings_disregard: 738 pension_credit_income: 10_914 - name: The same claimant with savings and no earnings has the same Pension Credit income. @@ -456,7 +458,7 @@ # 12,000 - 1,086. pension_credit_income: 10_914 -- name: Tax on earnings is net of the pension tax relief the earnings unlock. +- name: Pension contribution relief already available without earnings does not reduce tax attributed to earnings. period: 2024 input: people: @@ -472,16 +474,17 @@ household: members: [claimant] output: - # The earnings add 400 x 20% = 80 of tax but let the 300 of contributions - # be relieved, worth 60: income tax is 506, against 486 without them - # (next case), so tax on the earnings is 20. Net earnings are 400 - 20 - - # 300 / 2 = 230, under the 5 pound disregard of 260. + # Relief is available on these 300 of contributions even without wages + # (FA 2004 s.190's 3,600 basic amount, age under 75). With wages tax is + # (15,400 - 12,570 - 300) x 20% = 506; without, it is 426 (next case). + # Employment tax is 80, so net earnings/disregard are + # 400 - 80 - 300 / 2 = 170, under the 5 pound cap of 260. income_tax: 506 - pension_credit_employment_income_tax: 20 - pension_credit_earnings_disregard: 230 - # 15,400 - 506 - 150 - 230: more than without the earnings, so earning - # more never raises Pension Credit. - pension_credit_income: 14_514 + pension_credit_employment_income_tax: 80 + pension_credit_earnings_disregard: 170 + # 15,400 - 506 - 150 - 170 = 14,574; the net-earnings floor means PC + # income is 150 more than without wages (next case). + pension_credit_income: 14_574 - name: The same claimant with no earnings. period: 2024 @@ -498,10 +501,12 @@ household: members: [claimant] output: - income_tax: 486 + # No wages are needed for relief on the 300 contribution below the + # FA 2004 s.190 basic amount: (15,000 - 12,570 - 300) x 20% = 426. + income_tax: 426 pension_credit_earnings_disregard: 0 - # 15,000 - 486 - 300 / 2. - pension_credit_income: 14_364 + # 15,000 - 426 - 300 / 2 = 14,424. + pension_credit_income: 14_424 - name: A self-employment loss is not set against wages in the disregard (CER 1996 reg 13(12)). period: 2025 diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 95cbe06ea7..5125b98dd2 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -146,9 +146,9 @@ def calculate(sim, year, order=OUTPUTS): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML cases, run on every invocation: random draws seldom raise CTC or -# hit the third row. Pension Credit earnings are net of SPC Regs Sch VI -# para 5; the CTC family element is entered at its statutory value (see the -# YAML file). +# hit the third row. Employment income is gross; the model applies SPC Regs +# Sch VI para 5 once. The CTC family element is entered at its statutory +# value (see the YAML file). _PENSIONER = { "age": 70, "employment_income": 2_600, @@ -157,21 +157,27 @@ def calculate(sim, year, order=OUTPUTS): "working_tax_credit_reported": 1, } YAML_FAMILIES = [ - # Guarantee Credit with WTC: passported. + # Gross wages 2,600 - single disregard 5 x 52 = 2,340. With passported + # WTC 2,435, PC is 218.15 x 52 - 6,000 - 2,340 - 2,435 = 568.80. ( [{**_PENSIONER, "state_pension": 6_000}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Pension Credit only on the income-tested award: the third row. + # The same gross wages leave 2,340 after Sch VI. Passported WTC gives + # PC 11,343.80 - 6,600 - 2,340 - 2,435 < 0, so the passport does not hold; + # income-tested WTC is 2,435 - (9,200 - 7,955) x 41% = 1,924.55, and + # PC remains 11,343.80 - 6,600 - 2,340 - 1,924.55 = 479.25. ( [{**_PENSIONER, "state_pension": 6_600}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Savings Credit alone, with CTC raised by the passport. + # Gross wages 3,000 - couple disregard 10 x 52 = 2,480; pensions 17,000 + # give PC income 19,480. The 989.976 maximum SC less + # (19,480 - 17,313.40) x 40% gives 123.336, so CTC is passported. ( [ { @@ -185,7 +191,6 @@ def calculate(sim, year, order=OUTPUTS): ], [{"age": 10}], { - "pension_credit_earnings": 2_480, "CTC_family_element": 545, }, {"savings": 10_000}, @@ -202,7 +207,8 @@ def calculate(sim, year, order=OUTPUTS): {"has_mixed_age_couple_pension_credit_saving": False}, {"savings": 10_000}, ), - # The same couple with the saving: Guarantee Credit, passported. + # With the saving, gross wages 2,600 - couple disregard 520 = 2,080. + # PC is 17,313.40 - 6,000 - 2,080 - passported WTC 4,935 = 4,298.40. ( [ {**_PENSIONER, "state_pension": 6_000, "months_since_last_birthday": 9}, @@ -211,7 +217,6 @@ def calculate(sim, year, order=OUTPUTS): [], { "has_mixed_age_couple_pension_credit_saving": True, - "pension_credit_earnings": 2_080, }, {"savings": 10_000}, ), @@ -333,7 +338,9 @@ def test_passport_works_in_traced_and_nested_branch_simulations(): } ], [], - {"pension_credit_earnings": 2_340}, + # Gross wages 2,600 less Sch VI 260 leave 2,340; PC is + # 218.15 x 52 - 6,000 - 2,340 - passported WTC 2,435 = 568.80. + {}, {}, ) for trace in [False, True]: diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 85b09326d0..70d7fee070 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -29,7 +29,9 @@ award equal the same family with those profits cut by the loss. The model's means tests use annual tax deductions, so otherwise the loss reaches them through those deductions. UC's own deductions for tax and - NI on earnings are held alongside Income Tax and NI. + NI on earnings and Pension Credit's tax attribution to employed and + self-employed earnings are held alongside Income Tax and NI, including + the actual Class 2/4 components replaced by notional PC deductions. 5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses (not a child's) come off their applicable income, never below zero, and the award never falls. @@ -610,6 +612,24 @@ def values(sim, variable): @PROPERTY_SETTINGS @example(PINNED_FAMILIES) +@example( + [ + ( + [ + adult(age=18, self_employment_income=17_809.094330166412), + {"age": 0, "trading_loss": 3_692.5341857798658}, + ], + home(1.0, "NORTH_EAST"), + ), + # Actual Class 4 falls to zero with this loss, while CER deductions + # still use the separate positive employment's 30,000 profit. Holding + # total NI also requires holding its actual Class 4 component. + ( + [adult(self_employment_income=30_000, trading_loss=20_000)], + home(0.0), + ), + ] +) @given(st.lists(families(), min_size=4, max_size=10)) def test_means_tests_see_the_loss_only_through_tax(units): with_loss = Simulation(situation=family_situation(units)) @@ -622,10 +642,30 @@ def values(sim, variable): deductions = [ "income_tax", "national_insurance", + # PC replaces these actual components with CER notional deductions; + # hold them consistently with the total national_insurance input. + "ni_class_2", + "ni_class_4", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", ] tax = {v: values(with_loss, v) for v in deductions} + # SPC Regs reg 17(11) substitutes CER notional deductions for the tax + # actually attributable to profits. Holding income_tax alone also holds + # it in the no-profit counterfactual, incorrectly setting that attribution + # to zero. Preserve both PC tax allocations from the with-loss side. + # In the child-loss example the adult has no loss: actual/notional tax + # is (17,809.09433 - 12,570) x 20% = 1,047.818866, and notional Class 4 + # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives + # PC income 17,809.09433 - 1,047.818866 - 314.345660 - 1,040 = 15,406.93 + # on both sides; the child's loss never enters the claimant's earnings. + pc_tax = { + v: values(with_loss, v) + for v in [ + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + ] + } loss = values(with_loss, "trading_loss") profits = values(with_loss, "self_employment_income") mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) @@ -644,7 +684,13 @@ def held(k, **extra): situation=family_situation( units, {name: held(k) for k, name in enumerate(names)}, - {b: {"universal_credit": float(uc[k])} for k, b in enumerate(benunits)}, + { + b: { + "universal_credit": float(uc[k]), + **{v: float(pc_tax[v][k]) for v in pc_tax}, + } + for k, b in enumerate(benunits) + }, ) ) for variable in MEANS_TESTED: From d157482c7ce2f41bba844b17396e76a146e3c706 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:30:01 -0400 Subject: [PATCH 088/141] test: remove pre-netted Pension Credit child amount inputs --- ...edit_child_amount_tax_credit_properties.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py index 87929cd78a..1ce961f531 100644 --- a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py +++ b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py @@ -158,8 +158,9 @@ def schedule_iia_amount(drawn, year): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML households (child_amount_tax_credit_award.yaml), run on every -# invocation. Pension Credit earnings are net of SPC Regs Sch VI para 5, and -# the CTC family element is entered at its statutory £545. The last two pay +# invocation. Wages are gross: the first three families' £2,600 less the +# SPC Regs Sch VI para 5 couple disregard of £10 x 52 gives £2,080 once. +# The CTC family element is entered at its statutory £545. The last two pay # rent, so the order and differential checks reach Housing Benefit; they # enter the Housing Benefit claim, which the model otherwise infers from # reported benefits across the whole simulation. @@ -170,10 +171,12 @@ def schedule_iia_amount(drawn, year): "state_pension": 4_000, "savings_interest_income": 300, } -_INPUTS = {"would_claim_uc": False, "pension_credit_earnings": 2_080} +_INPUTS = {"would_claim_uc": False} _RENTED = {"savings": 18_000, "rent": 12_000, "tenure_type": "RENT_FROM_COUNCIL"} YAML_FAMILIES = [ - # CTC and WTC with Pension Credit: passported, no child amount. + # CTC and WTC with PC: pensions 8,000 + net earnings 2,080 + passported + # WTC 4,935 = income 15,015; GC is 332.95 x 52 - 15,015 = 2,298.40. + # A tax-credit award excludes the 76.79 x 52 = 3,993.08 child amount. ( [ { @@ -187,14 +190,16 @@ def schedule_iia_amount(drawn, year): {**_INPUTS, "CTC_family_element": 545}, {"savings": 10_000}, ), - # No tax credit claim: the child amount. + # No tax credit claim: pensions 8,000 + net earnings 2,080 = 10,080. + # With the child amount, GC is 17,313.40 + 3,993.08 - 10,080 = 11,226.48. ( [_GRANDPARENT, {"age": 70, "state_pension": 4_000}], [{"age": 10}], _INPUTS, {"savings": 10_000}, ), - # WTC alone. + # WTC alone also excludes the child amount; the first family's income + # 15,015 and GC 2,298.40 are unchanged because CTC is not PC income. ( [ {**_GRANDPARENT, "working_tax_credit_reported": 1}, @@ -204,7 +209,11 @@ def schedule_iia_amount(drawn, year): _INPUTS, {"savings": 10_000}, ), - # A WTC award at a nil rate. + # Gross wages 3,500 - Sch VI 520 = 2,980, not a pre-netted earnings + # input. With pensions 17,000, income 19,980 exceeds guarantee 17,313.40. + # WTC is max(0, 4,935 - 41% x (20,500 - 7,955)) = 0, still an award. + # Without that award the child amount gives GC 21,306.48 - 19,980 = + # 1,326.48, satisfying the existing positive-award boundary assertion. ( [ { @@ -216,7 +225,7 @@ def schedule_iia_amount(drawn, year): {"age": 70, "state_pension": 8_000}, ], [{"age": 10}], - {**_INPUTS, "pension_credit_earnings": 2_980}, + _INPUTS, {"savings": 10_000}, ), # A CTC award ends the Guarantee Credit and its Housing Benefit passport. From 4e08807d1a0710cd8b08868e17eb9d3ca333d36c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 08:14:07 -0400 Subject: [PATCH 089/141] test: use gross earnings in Pension Credit passport fixtures --- .../dwp/tax_credits_pension_credit_passport.yaml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index 8de54538e5..5bbd3d5598 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -31,8 +31,9 @@ # Pension Credit tariff income (SPC Regs 2002 reg 15(6)), and savings interest # is not Pension Credit income. # -# Pension Credit earnings are entered net of the SPC Regs 2002 Sch VI para 5 -# disregard (£5 a week single, £10 a couple), which the model does not apply. +# Employment income is gross. The model applies the SPC Regs 2002 Sch VI +# para 5 disregard once (£5 a week single, £10 a couple); do not pre-net a +# pension_credit_earnings input by that amount. # No case has income tax: each person's income is within the personal # allowance. @@ -57,7 +58,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 # 2,600 - 5 x 52 households: household: members: [pensioner] @@ -92,7 +92,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -124,7 +123,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -153,7 +151,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -182,7 +179,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 would_claim_pc: false households: household: @@ -231,7 +227,6 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_480 # 3,000 - 10 x 52 CTC_family_element: 545 households: household: @@ -277,7 +272,6 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_480 CTC_family_element: 0 working_tax_credit_run_on: false extended_childcare_entitlement_eligible: false @@ -374,7 +368,6 @@ members: [older, younger] would_claim_uc: false has_mixed_age_couple_pension_credit_saving: true - pension_credit_earnings: 2_080 # 2,600 - 10 x 52 households: household: members: [older, younger] @@ -409,7 +402,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 standard_minimum_guarantee: 11_809.20 households: household: @@ -440,7 +432,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] From 3055d0c414f6755dc80153792593015c84f16a43 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 08:15:23 -0400 Subject: [PATCH 090/141] test: hold Pension Credit personal reliefs in trading-loss invariant --- .../tests/test_trading_loss_properties.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 70d7fee070..2b0c05f5b3 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -32,6 +32,8 @@ NI on earnings and Pension Credit's tax attribution to employed and self-employed earnings are held alongside Income Tax and NI, including the actual Class 2/4 components replaced by notional PC deductions. + The personal reliefs used for PC's notional tax are held too: the loss + can change entitlement to those reliefs without offsetting PC earnings. 5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses (not a child's) come off their applicable income, never below zero, and the award never falls. @@ -612,6 +614,120 @@ def values(sim, variable): @PROPERTY_SETTINGS @example(PINNED_FAMILIES) +@example( + [ + ( + [ + adult(age=57, trading_loss=2_399.8265339892096, hours_worked=0.0), + adult( + age=18, + employment_income=1.0, + self_employment_income=24_100.0, + savings_interest_income=1.195, + trading_loss=100.0, + hours_worked=0.0, + ), + {"age": 4, "trading_loss": 1.9}, + {"age": 11, "trading_loss": 0.0}, + {"age": 13, "trading_loss": 4_999.999999999999}, + ], + { + "rent": 0.0, + "council_tax": 905.6960743779131, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "SCOTLAND", + }, + ), + ( + [ + adult( + age=62, + employment_income=48_163.774854368, + private_pension_income=18.8, + savings_interest_income=3_726.67143339902, + hours_worked=0.0, + ), + adult( + age=45, + employment_income=35_394.709560981835, + self_employment_income=24_566.859819474743, + savings_interest_income=2_950.8270749404533, + trading_loss=33_435.74658513417, + hours_worked=16.0, + ), + {"age": 11, "trading_loss": 3_414.540906596419}, + {"age": 7, "trading_loss": 0.0}, + {"age": 3, "trading_loss": 0.0}, + ], + { + "rent": 8_205.896001170435, + "council_tax": 2_894.867267754779, + "tenure_type": "RENT_PRIVATELY", + "region": "NORTH_EAST", + }, + ), + ( + [ + adult( + age=55, + self_employment_income=22_244.933680665035, + private_pension_income=15_657.59819004124, + trading_loss=20_700.0, + ), + {"age": 8, "trading_loss": 2_806.337586256281}, + ], + { + "rent": 0.0, + "council_tax": 2_169.4036386087373, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "WALES", + }, + ), + ( + [ + adult( + age=23, + employment_income=1.1, + self_employment_income=16_997.525784723435, + trading_loss=27_557.714012466255, + hours_worked=16.0, + ) + ], + { + "rent": 0.0, + "council_tax": 1_748.2542780681022, + "tenure_type": "RENT_PRIVATELY", + "region": "WALES", + }, + ), + # CI's fifth family: the partner's loss enables a £1,260 Marriage + # Allowance. Removing it changes PC notional tax by £252 unless + # the statutory personal reliefs are held with the other tax inputs. + ( + [ + adult( + age=57, + self_employment_income=26_702.388163483578, + private_pension_income=7_714.38595373581, + savings_interest_income=4_509.347498481971, + hours_worked=16.0, + ), + adult( + age=31, + private_pension_income=17_970.97909886547, + trading_loss=10_351.972522786527, + ), + {"age": 11, "trading_loss": 0.0}, + ], + { + "rent": 374.4475438112428, + "council_tax": 2_056.072240654743, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "NORTH_EAST", + }, + ), + ] +) @example( [ ( @@ -648,6 +764,13 @@ def values(sim, variable): "ni_class_4", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", + # CER 13 uses these personal reliefs for PC's notional tax. A loss + # can alter them (for example by enabling a partner's Marriage + # Allowance transfer), so preserve their with-loss values as well. + "personal_allowance", + "blind_persons_allowance", + "marriage_allowance", + "married_couples_allowance_deduction", ] tax = {v: values(with_loss, v) for v in deductions} # SPC Regs reg 17(11) substitutes CER notional deductions for the tax From dcf8d5443693253d76ba4b8187714755ee1727a5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 08:15:23 -0400 Subject: [PATCH 091/141] docs: qualify Pension Credit earnings flatness for pension contributions --- .../dwp/pension_credit/pension_credit_earnings_income_tax.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py index cb30beb1d6..b922aafcb2 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -84,7 +84,8 @@ class pension_credit_employment_income_tax(Variable): "personal savings allowance) and any effect of one partner's earnings " "on the other's tax (through the marriage allowance), so Pension " "Credit income does not change with either partner's earnings while " - "the disregard covers them. " + DECISION + "the disregard covers them, provided the zero floor on net earnings " + "does not limit the pension-contribution deduction. " + DECISION ) definition_period = YEAR unit = GBP From 54b409db47fd543792a0f2ce5c9e2c43010ddb7e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 08:23:29 -0400 Subject: [PATCH 092/141] ci: retain Python collection after policy YAML failures --- .github/workflows/test-suite.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index c7f7fa8067..e0ea3c5f84 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -82,6 +82,9 @@ jobs: TESTS: ${{ steps.env.outputs.tests }} run: policyengine-core test "$TESTS/policy" -c policyengine_uk - name: List the Python tests + id: collect + # Keep Result's coverage audit available after a policy YAML failure. + if: ${{ !cancelled() && steps.env.outcome == 'success' }} # Every Python test, collected without --shard, in the environment # the shards use. Result checks that the shards ran each of these # exactly once. @@ -99,6 +102,7 @@ jobs: --collect-only -q -p no:cacheprovider > collected.txt tail -n 1 collected.txt - name: Upload the list of Python tests + if: ${{ !cancelled() && steps.collect.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: test-collected From 1d9405370acd1a577384fcadec1445c17d0f199e Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 20 Oct 2026 13:51:34 +0100 Subject: [PATCH 093/141] State that BSP rates are held flat and note the War Widow's Pension split SI 2026/148 art 19 keeps the four BSP amounts in SI 2017/410 reg 3 unchanged, so the planned parameters carry no uprating. Note that #2382 gave War Widow's Pension its own variables and that bsp_reported keeps CPI uprating only until the datasets move FRS code 9 out (#2381). Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/bereavement-support-plan.md | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/book/programs/gov/dwp/bereavement-support-plan.md b/docs/book/programs/gov/dwp/bereavement-support-plan.md index a8160f9926..9499139048 100644 --- a/docs/book/programs/gov/dwp/bereavement-support-plan.md +++ b/docs/book/programs/gov/dwp/bereavement-support-plan.md @@ -44,6 +44,14 @@ That means: [source notes](https://github.com/PolicyEngine/microcosm/blob/d1fda720/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml#L1870) say code 6 mixes benefits and code 9 is tax-free War Widow's Pension. So the reported "BSP" also carries legacy widow's benefits and War Widow's Pension. +- Since [#2382](https://github.com/PolicyEngine/policyengine-uk/pull/2382) + (2.135.7), War Widow's Pension has its own variables, + `war_widows_pension_reported` and `war_widows_pension`. `bsp_reported` + keeps CPI uprating only until the datasets move FRS code 9 out of it + ([policyengine-uk-data#578](https://github.com/PolicyEngine/policyengine-uk-data/issues/578), + [microcosm#1222](https://github.com/PolicyEngine/microcosm/issues/1222)); + [#2381](https://github.com/PolicyEngine/policyengine-uk/issues/2381) + tracks freezing it after that. ## Proposed scope @@ -96,6 +104,12 @@ derived. Either way the lump sum is tied to one year only. Both `lump_sum_amount` and `monthly_amount` parameter trees split on `bsp_higher_rate_eligible` (£3,500 / £350 vs £2,500 / £100). +These parameters should be held flat, with no `uprating` key. BSP is +not uprated: the four amounts in [SI 2017/410 reg 3][bsp-reg-3] have +been £350, £3,500, £100 and £2,500 since April 2017, and the +[Social Security Benefits Up-rating Order 2026][uprating-2026] +(SI 2026/148), art 19, says each of them "remains unchanged". + ### Phase 2: historical WPA under the same formula Widowed Parent's Allowance (under [Social Security Contributions and @@ -139,14 +153,6 @@ directly) but not for microsim aggregates. ## Open questions -- BSP isn't uprated each year. The rates have been £2,500/£3,500 and - £100/£350 since 2017. The model's `bsp_reported` is nonetheless - uprated with CPI, which suits the War Widow's Pension and legacy - widow's benefits it also contains but not BSP itself. Should the new - parameter tree carry an explicit - `uprating: gov.economic_assumptions.indices.obr.consumer_price_index` - to mirror what other DWP rates do, or hold flat to match the - historical reality? - WPA recipients who would have been on BSP under the new rules: do we want a reform-side parameter "treat all WPA receipts as if they were BSP" for counterfactual analysis? @@ -159,6 +165,7 @@ directly) but not for microsim aggregates. - [Pensions Act 2014, Part 5](https://www.legislation.gov.uk/ukpga/2014/19/part/5): primary statute for BSP. - [Bereavement Support Payment Regulations 2017 (SI 2017/410)][bsp-regs]. +- [Social Security Benefits Up-rating Order 2026 (SI 2026/148), art 19][uprating-2026]: holds the four BSP amounts unchanged. - [Bereavement Benefits (Remedial) Order 2023 (SI 2023/134)](https://www.legislation.gov.uk/uksi/2023/134/contents): cohabiting-parent extension, effective from 30 August 2018. - [SSCBA 1992 s. 39A][sscba-39a]: legacy WPA basis. - gov.uk, [Bereavement Support Payment](https://www.gov.uk/bereavement-support-payment) and [Widowed Parent's Allowance](https://www.gov.uk/widowed-parents-allowance). @@ -167,4 +174,6 @@ directly) but not for microsim aggregates. [pa-2014-5]: https://www.legislation.gov.uk/ukpga/2014/19/part/5 [bsp-regs]: https://www.legislation.gov.uk/uksi/2017/410/contents +[bsp-reg-3]: https://www.legislation.gov.uk/uksi/2017/410/regulation/3 +[uprating-2026]: https://www.legislation.gov.uk/uksi/2026/148/article/19 [sscba-39a]: https://www.legislation.gov.uk/ukpga/1992/4/section/39A From ffa476e6f654d5a174c511165b38f4d19df41e0c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 20 Oct 2026 15:55:22 +0100 Subject: [PATCH 094/141] Fix inactive UC health counterfactual 2026 CPI anchor --- changelog.d/2283.fixed.md | 2 +- docs/book/policy/uc-rebalancing.md | 2 +- policyengine_uk/scenarios/uc_reform.py | 23 ++++++++-------- policyengine_uk/tests/test_uc_rebalancing.py | 29 +++++++++++++++++++- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md index f343249681..0285a05397 100644 --- a/changelog.d/2283.fixed.md +++ b/changelog.d/2283.fixed.md @@ -1 +1 @@ -When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. +When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index f98ea2ad6d..0c5cb4382f 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -92,7 +92,7 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` -For the health component, an inactive year uses the 2025-26 monthly LCWRA amount (£423.27), uprated by the model's ordinary benefit uprating index from 2025-26 to that year. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that pre-Act amount, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. +For the health component, the inactive 2026-27 amount is a declared CPI-only counterfactual: the 2025-26 monthly LCWRA amount (£423.27) increased by September 2025 CPI (3.8%) and rounded to monthly pennies (£439.35, or £5,272.20 a year). SSAA 1992 section 150 allows discretion over Universal Credit uprating and rounding; this convention is not an enacted LCWRA rate or the only legally possible amount. From 2027-28, the model's ordinary benefit uprating index projects growth from that 2026-27 anchor. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that fixed 2026-27 anchor, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. The scenario covers tax years through 2032-33; from 2033-34 the inactive setting falls back to the underlying amount path, so it does not provide a continuous long-run “Act off” projection. The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index 61d0e4862b..eaa03ea02d 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -246,21 +246,22 @@ def _lcwra_amount_is_overridden(sim, year: int) -> bool: def _inactive_health_element_monthly(sim, year: int) -> float: - """The pre-Act amount, uprated independently from the last pre-Act year. + """The CPI-only counterfactual, independent of previous paid awards. - The Act's new-claimant anchor remains on the underlying parameter. A - year with rebalancing switched off instead follows the ordinary benefit - index from the 2025-26 amount; it never reads a prior paid award. + Anchor 2026-27 to the pre-Act amount increased by September 2025 CPI, + rounded to monthly pennies. This is a scenario convention, not an + enacted LCWRA rate or a uniquely required SSAA 1992 s.150 rounding rule. + Later years follow the ordinary benefit index from that 2026 anchor. """ parameters = sim.tax_benefit_system.parameters - base_year = FIRST_REBALANCING_YEAR - 1 - before = parameters(str(base_year)).gov - current = parameters(str(year)).gov - return ( - before.dwp.universal_credit.elements.disabled.amount - * current.benefit_uprating_cpi - / before.benefit_uprating_cpi + before = parameters(str(FIRST_REBALANCING_YEAR - 1)).gov + anchor = round( + float(before.dwp.universal_credit.elements.disabled.amount) + * _relevant_cpi_factor(sim, FIRST_REBALANCING_YEAR), + 2, ) + anchor_index = parameters(str(FIRST_REBALANCING_YEAR)).gov.benefit_uprating_cpi + return anchor * parameters(str(year)).gov.benefit_uprating_cpi / anchor_index # Only functions registered here are this internal amount wrapper. External diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index be1c70c57e..f671a14ba8 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -412,13 +412,14 @@ def test_a_branch_can_state_its_own_status(): # The inactive health-component counterfactual starts from pre-Act 2025 law, # independently of the new/protected claimant split (issue #2283). +# The independently sourced 2026 CPI-only monthly-penny anchor is £439.35. def _inactive_lcwra_expected(sim, year): parameters = sim.tax_benefit_system.parameters def index(y): return float(parameters(str(y)).gov.benefit_uprating_cpi) - return LCWRA_2025 * index(year) / index(2025) * 12 + return 439.35 * index(year) / index(2026) * 12 @pytest.mark.parametrize("year", [2026, 2027]) @@ -603,3 +604,29 @@ def test_inactive_cached_awards_follow_public_late_amount_reform(repeat_modifier sim.calculate("uc_LCWRA_element", 2026)[0], sim.calculate("universal_credit", 2026)[0], ] == pytest.approx([3_600, 8_698.80], rel=0, abs=0.01) + + +def test_inactive_2026_uses_penny_rounded_september_cpi_anchor(): + # Declared CPI-only counterfactual: DWP 2025 LCWRA £423.27, September + # 2025 CPI 3.8%; round the monthly £439.35426 to £439.35, then annualise. + # SSAA 1992 s.150 permits discretion; this is not an enacted LCWRA rate. + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", 2026)[0] + assert actual > 0 + assert actual == pytest.approx(5_272.20, rel=0, abs=0.01) + assert actual / 12 == pytest.approx(439.35, rel=0, abs=0.001) + # Existing legislated SA stays separate: single25+ £424.90/month. + assert sim.calculate("uc_standard_allowance", 2026)[0] == pytest.approx( + 424.90 * 12, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 10_371.00, rel=0, abs=0.01 + ) From 370aade34e56ad36835d43bc2648604d6b61eceb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 11:13:40 -0400 Subject: [PATCH 095/141] Count CA earnings-limit pence in the model's float32 in the property test test_bounds_and_differential recomputed the reg. 5 penny rounding on earnings converted to float64, while is_gainfully_employed_for_carers_allowance floors the weekly pence on the stored float32 value. At pay of 10,608.52 in 2026 (stored as 10,608.51953125) the formula counts 20,401 weekly pence and the float64 recomputation 20,400, so a draw there failed at random. Keep each variable's stored dtype in _person() and count the pence on it through _over_limit(); tolerance checks convert with float(). Add explicit examples at 10,608.00 (exactly at the limit) and 10,608.52 (a penny over), and derandomize the three property tests. Fixes #2532 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2532.fixed.md | 1 + ...ers_allowance_earnings_limit_properties.py | 44 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2532.fixed.md diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md new file mode 100644 index 0000000000..3b23ecec4b --- /dev/null +++ b/changelog.d/2532.fixed.md @@ -0,0 +1 @@ +- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), so a draw just over a penny boundary no longer fails at random. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index 497d9ad7f9..a75d71455d 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -14,8 +14,11 @@ 1. Bounds: 0 <= carers_allowance_earnings <= pay + max(0, profit). 2. Differential: the test is passed exactly when - carers_allowance_earnings / 52 <= 204, and the allowance is the full rate - then and nil otherwise. + carers_allowance_earnings / 52, with a fraction of a penny disregarded + (Computation of Earnings Regulations 1996 reg. 5), is at most 204, and the + allowance is the full rate then and nil otherwise. The pence are counted + in the precision the model stores earnings in (float32), as the formula + counts them. 3. Monotonic: raising pay never lowers net earnings, so a person over the limit stays over it. 4. Unearned income is not earnings: savings interest and pension income @@ -24,7 +27,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -54,8 +57,10 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): }, } sim = Simulation(situation=situation) + # Keep each variable's stored dtype: the reg. 5 check must count pence in + # the precision the formula does. Tolerance checks convert with float(). return { - name: float(np.asarray(sim.calculate(name, 2026))[0]) + name: np.asarray(sim.calculate(name, 2026))[0] for name in ( "carers_allowance_earnings", "is_gainfully_employed_for_carers_allowance", @@ -64,6 +69,15 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): } +def _over_limit(earnings): + # Regulation 5: a fraction of a penny is disregarded. ``earnings`` is the + # stored value, so this counts the pence as + # is_gainfully_employed_for_carers_allowance does, in float32: promoting + # to float64 first floors 10,608.52 a year, stored as 10,608.51953125, to + # 204.00 a week instead of 204.01. + return np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + + money = st.floats(min_value=0, max_value=60_000, allow_nan=False) profit_money = st.floats(min_value=-20_000, max_value=60_000, allow_nan=False) @@ -71,24 +85,30 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): @settings( max_examples=12, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, profit=profit_money, pension_share=st.floats(0, 0.3)) +# Exactly at the 2026-27 limit (10,608 / 52 = 204.00): not gainfully employed. +@example(pay=10_608.0, profit=0.0, pension_share=0.0) +# One penny a week over it (10,608.52 / 52 = 204.01), on the float32 boundary +# described in _over_limit: gainfully employed. +@example(pay=10_608.52, profit=0.0, pension_share=0.0) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share result = _person(pay, profit, pension) earnings = result["carers_allowance_earnings"] - assert -0.01 <= earnings <= pay + max(0, profit) + 0.01 - # Regulation 5: a fraction of a penny is disregarded. - over = np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + assert -0.01 <= float(earnings) <= pay + max(0, profit) + 0.01 + over = _over_limit(earnings) assert bool(result["is_gainfully_employed_for_carers_allowance"]) == over expected = 0 if over else RATE * WEEKS - assert abs(result["carers_allowance"] - expected) < 0.01 + assert abs(float(result["carers_allowance"]) - expected) < 0.01 @settings( max_examples=8, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, rise=st.floats(min_value=1, max_value=20_000)) @@ -96,7 +116,8 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): lower = _person(pay, 0, 0) higher = _person(pay + rise, 0, 0) assert ( - higher["carers_allowance_earnings"] >= lower["carers_allowance_earnings"] - 0.01 + float(higher["carers_allowance_earnings"]) + >= float(lower["carers_allowance_earnings"]) - 0.01 ) if lower["is_gainfully_employed_for_carers_allowance"]: assert higher["is_gainfully_employed_for_carers_allowance"] @@ -105,6 +126,7 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): @settings( max_examples=6, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given( @@ -117,8 +139,8 @@ def test_unearned_income_is_not_earnings(pay, savings, private_pension): with_unearned = _person(pay, 0, 0, savings=savings, private_pension=private_pension) assert ( abs( - alone["carers_allowance_earnings"] - - with_unearned["carers_allowance_earnings"] + float(alone["carers_allowance_earnings"]) + - float(with_unearned["carers_allowance_earnings"]) ) < 0.01 ) From b7ac5ea57d413b2db82ff17a389aa4985fd0e316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:38:24 +0100 Subject: [PATCH 096/141] Fix historical income-tax band thresholds --- changelog.d/2356.fixed.md | 1 + .../tax_band_historical.yaml | 222 ++++++++++++++++++ .../bracketized_liability/tax_band.py | 6 +- 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2356.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml diff --git a/changelog.d/2356.fixed.md b/changelog.d/2356.fixed.md new file mode 100644 index 0000000000..a14daae927 --- /dev/null +++ b/changelog.d/2356.fixed.md @@ -0,0 +1 @@ +Fix the default historical income-tax band thresholds and strict upper boundaries, including Marriage Allowance eligibility in 2016 and 2017. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml new file mode 100644 index 0000000000..9f5ce9390c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml @@ -0,0 +1,222 @@ +# ITA 2007 s.10(2), (3), (3A): equality remains in the lower band. +# FA 2015 s.4, as amended: basic limits 32,000 (2016-17), 33,500 (2017-18). +# FA 2015 s.5, as amended: personal allowances 11,000 and 11,500. +# ITA 2007 s.55B(2)(b): a higher-rate recipient cannot receive the transfer. +# FA 2016 s.1(2) and FA 2017 s.2: basic/higher rates 20%/40%. +# Annual labels use fiscal-year parameters but formula dispatch at 1 January. +# Model-resident married adults below MCA age, no other income/reliefs, +# and default claimed-election/take-up assumptions. This does not audit every +# historical statutory election or devolved-rate condition. + +- name: Case 1, higher-rate spouse cannot receive Marriage Allowance in 2016. + # 32000 * 20% + (60,000 - 11000 - 32000) * 40% = 13200. + period: 2016 + input: + people: + person1: + age: 40 + employment_income: 60_000 + person2: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [person1, person2] + is_married: true + households: + household: + members: [person1, person2] + country: ENGLAND + output: + personal_allowance: [11000, 11000] + net_income: [60_000, 5_000] + tax_band: [HIGHER, NONE] + meets_marriage_allowance_income_conditions: [false, true] + marriage_allowance: [0, 0] + income_tax: [13200, 0] + +- name: Case 2, higher-rate spouse cannot receive Marriage Allowance in 2017. + # 33500 * 20% + (60,000 - 11500 - 33500) * 40% = 12700. + period: 2017 + input: + people: + person1: + age: 40 + employment_income: 60_000 + person2: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [person1, person2] + is_married: true + households: + household: + members: [person1, person2] + country: ENGLAND + output: + personal_allowance: [11500, 11500] + net_income: [60_000, 5_000] + tax_band: [HIGHER, NONE] + meets_marriage_allowance_income_conditions: [false, true] + marriage_allowance: [0, 0] + income_tax: [12700, 0] + +- name: Case 3, the basic-rate limit stays basic. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 43000 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 4, income above the basic-rate limit is higher. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 43000.01 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 5, the later historical basic-rate limit stays basic. + # Classifier unit contract with supplied net income/allowances. + period: 2017 + input: + net_income: 45000 + allowances: 11500 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 6, income above the later basic-rate limit is higher. + # Classifier unit contract with supplied net income/allowances. + period: 2017 + input: + net_income: 45000.01 + allowances: 11500 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 7, the additional-rate limit stays higher. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 150000 + allowances: 0 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 8, the next float32 value above the additional-rate limit is additional. + # Classifier unit contract with supplied net income/allowances. + # 0.015625 is one representable float32 step at 150,000, not a tolerance. + period: 2016 + input: + net_income: 150000.015625 + allowances: 0 + gift_aid_grossed_up: 0 + output: + tax_band: ADDITIONAL + meets_marriage_allowance_income_conditions: false + +- name: Case 9, zero income remains below the allowance. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 0 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: NONE + meets_marriage_allowance_income_conditions: true + +- name: Case 10, income at the allowance remains untaxed. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 11000 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: NONE + meets_marriage_allowance_income_conditions: true + +- name: Case 11, income above the allowance is basic. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 11000.01 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 12, Gift Aid extends the basic-rate limit. + # Classifier unit contract with supplied net income/allowances. + # ITA 2007 s.414(2)(b): grossed-up gift extends both limits by 2,000. + period: 2016 + input: + net_income: 45000 + allowances: 11000 + gift_aid_grossed_up: 2000 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 13, income above the extended Gift Aid limit is higher. + # Classifier unit contract with supplied net income/allowances. + # ITA 2007 s.414(2)(b): grossed-up gift extends both limits by 2,000. + period: 2016 + input: + net_income: 45000.01 + allowances: 11000 + gift_aid_grossed_up: 2000 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 14, earliest model year uses the actual bracket slots. + # Classifier unit contract with supplied net income/allowances. + # Parameter-consistency control only; no full 2015 rate-law audit. + period: 2015 + input: + net_income: 60000 + allowances: 10600 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 15, modern rest-of-UK routing remains unchanged away from a boundary. + # Existing later-method behavior preservation, not a new historical rate audit. + period: 2018 + input: + net_income: 60_000 + allowances: 11_850 + pays_scottish_income_tax: false + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 16, modern Scottish routing remains unchanged away from a boundary. + # Existing 2026 parameter/dispatch control: 17,430 falls in the intermediate band. + # This does not audit unrelated Scottish dates or exact-boundary behavior. + period: 2026 + input: + net_income: 30_000 + allowances: 12_570 + pays_scottish_income_tax: true + output: + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 62f4794ba6..73f78c28d5 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -33,10 +33,10 @@ def formula(person, period, parameters): for index, threshold in enumerate(rates.uk.thresholds) ] basic = allowances + limits[0] - higher = allowances + limits[-2] - add = allowances + limits[-1] + higher = allowances + limits[1] + add = allowances + limits[2] return select( - [ani >= add, ani >= higher, ani > basic], + [ani > add, ani > higher, ani > basic], [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], default=TaxBand.NONE, ) From 3b466a0b7f89d471eff7745175fe6161f67730d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 16:40:57 -0400 Subject: [PATCH 097/141] Pin the 2026 penny-over outcome and cover a trading loss From the GPT-6.1 Sol review of #2534: - Add test_a_penny_a_week_over_the_2026_limit_ends_entitlement, which asserts the outcome for 10,608.00 (not gainfully employed, full rate) and 10,608.52 (gainfully employed, nil) directly, not only agreement with _over_limit. - Add an @example with a trading loss and earnings below the limit (pay 8,000, profit -5,000, pension share 0.1); the derandomized draws have neither. - Correct the changelog: the value the float64 recompute got wrong is stored just below the exact boundary, and float32 arithmetic rounds it across. The float32 formula's own boundary behaviour is #2541. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2532.fixed.md | 2 +- ...carers_allowance_earnings_limit_properties.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md index 3b23ecec4b..ec250d10d2 100644 --- a/changelog.d/2532.fixed.md +++ b/changelog.d/2532.fixed.md @@ -1 +1 @@ -- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), so a draw just over a penny boundary no longer fails at random. +- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), as the formula does, so a draw whose float32 arithmetic rounds across a penny boundary no longer fails at random. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index a75d71455d..1613bc2003 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -94,6 +94,9 @@ def _over_limit(earnings): # One penny a week over it (10,608.52 / 52 = 204.01), on the float32 boundary # described in _over_limit: gainfully employed. @example(pay=10_608.52, profit=0.0, pension_share=0.0) +# A trading loss, with earnings below the limit: the derandomized draws have +# neither. +@example(pay=8_000.0, profit=-5_000.0, pension_share=0.1) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share result = _person(pay, profit, pension) @@ -105,6 +108,19 @@ def test_bounds_and_differential(pay, profit, pension_share): assert abs(float(result["carers_allowance"]) - expected) < 0.01 +@pytest.mark.parametrize("pay, gainful", [(10_608.00, False), (10_608.52, True)]) +def test_a_penny_a_week_over_the_2026_limit_ends_entitlement(pay, gainful): + """£10,608 a year is £204.00 a week, at the limit; £10,608.52 is £204.01, a + penny over it. This pins the outcome for the amount entered, not only + agreement with _over_limit. The model stores £10,608.52 as 10,608.51953125 + (£204.00 a week in exact arithmetic) and reaches £204.01 through float32 + rounding; see #2541.""" + result = _person(pay, 0, 0) + assert bool(result["is_gainfully_employed_for_carers_allowance"]) == gainful + expected = 0 if gainful else RATE * WEEKS + assert abs(float(result["carers_allowance"]) - expected) < 0.01 + + @settings( max_examples=8, deadline=None, From 4b996b57ffaa6dccaac731ba7fab77e916e99736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:47:24 +0100 Subject: [PATCH 098/141] Keep the bus fare cap schedules on their calendar dates The preserve_calendar_dates flag sat on the fares and reference_fares nodes, and leaves only see node metadata that is propagated. The caps were therefore annualised like any other parameter, which happened to be harmless while every change fell on 1 January. A cap change dated part-way through a year waited for the next calendar year. Propagate the flag to the cap leaves. Co-Authored-By: Claude Opus 5.5 --- .../parameters/gov/dft/bus/fares/index.yaml | 4 ++++ .../gov/dft/bus/reference_fares/index.yaml | 4 ++++ .../tests/test_bus_fare_reforms.py | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index e1614e9c38..144411cefe 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -140,6 +140,25 @@ def test_caps_apply_before_annual_averaging_and_preserve_january_changes(): ) +def test_a_cap_change_part_way_through_a_year_keeps_its_date(): + simulation = Simulation( + dataset=bus_dataset(), + scenario=Scenario( + parameter_changes={"gov.dft.bus.fares.cap": {"2026-10-01.2100-12-31": 1}} + ), + ) + bus = simulation.tax_benefit_system.parameters.gov.dft.bus + assert bus.fares.cap.metadata["preserve_calendar_dates"] + assert bus.reference_fares.cap.metadata["preserve_calendar_dates"] + assert bus.fares("2026-09-30").cap == 3 + assert bus.fares("2026-10-01").cap == 1 + # 178 days at £3 from 6 April, then 187 at £1. Annualised, the change + # would have waited for the 2027 calendar year. + assert annual_capped_fare(4, bus.fares, 2026) == pytest.approx( + (178 * 3 + 187 * 1) / 365 + ) + + def test_clone_does_not_lose_journey_input_provenance(): original = Simulation(dataset=bus_dataset()) expected = original.calculate("bus_fare_spending", 2024)[0] From f1b290954a4565f4deb6ff1b4641ab601ca16560 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:09:43 -0400 Subject: [PATCH 099/141] Label the per-recipient growth as pre-fix; state the forecast-year floor and two simplifications From the independent review of 4e495b77e: the 17.0% per-recipient growth came from runs before this fix; from April 2027 SSAA 1992 ss.150(2) and 151A(3) set a floor ("not less than" the rise in prices) that the model pays exactly; contracted-out records and additional pension inside a partial basic pension are simplified. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 3e06a70b6c..ddfd7a84a3 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -55,6 +55,22 @@ September CPI (article 11 of each Order). Until the fix for [#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941) the model uprated the part above the full rate by the full rate too. +For the years the Up-rating Orders cover (to April 2026), the model +reproduces each Order's percentage exactly. For later years the Act sets a +floor, not a rate: the Order must raise additional pensions "by a percentage +not less than" the rise in prices (s.150(2)), and protected payments by no +less (s.151A(3)). The model pays exactly September CPI, the floor and what +every Order has given. A reform that raises the part above the full rate by +more has no parameter to set; it needs a reform to this variable. + +Two simplifications remain. For a contracted-out record the Order's increase +applies to the gross additional pension, part of which the State pays on the +guaranteed minimum pension, so uprating only the reported amount above the +full rate by September CPI understates that record's growth. And additional +pension inside a reported total below the full basic rate is not separated, +so it is uprated with the full rate (as `overlapping_state_pension` also +notes). + So a reform to the full rates, or to the triple lock, moves only basic and new State Pension. A reform that sets a full rate to zero leaves additional pensions and protected payments in place; `gov.contrib.abolish_state_pension` @@ -407,8 +423,11 @@ tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): - **Awards.** Each record keeps its reported amount, the part up to the full rate uprated by the flat rate and the part above it by September CPI, including records whose cohort moves from basic to new State - Pension. The model's State Pension per recipient grows 17.0% over those - years. DWP's spending per recipient, less those paid abroad, grows 18.3%, + Pension. In the runs behind the table above, which predate the fix for + #1941 and uprated the part above the full rate by the flat rate too, the + model's State Pension per recipient grew 17.0% over those years; uprating + that part by September CPI makes it grow more slowly. DWP's spending per + recipient, less those paid abroad, grows 18.3%, as its new State Pension spending grows from £56.1bn to £102.4bn and its basic State Pension spending falls from £66.7bn to £57.8bn. From fa27e494826858baf2732cdb00efefad4941f2ea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:10:02 -0400 Subject: [PATCH 100/141] Drop an unverified contracted-out note from the State Pension docs Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/state-pension.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index ddfd7a84a3..0430650f94 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -63,13 +63,9 @@ less (s.151A(3)). The model pays exactly September CPI, the floor and what every Order has given. A reform that raises the part above the full rate by more has no parameter to set; it needs a reform to this variable. -Two simplifications remain. For a contracted-out record the Order's increase -applies to the gross additional pension, part of which the State pays on the -guaranteed minimum pension, so uprating only the reported amount above the -full rate by September CPI understates that record's growth. And additional -pension inside a reported total below the full basic rate is not separated, -so it is uprated with the full rate (as `overlapping_state_pension` also -notes). +One simplification remains: additional pension inside a reported total +below the full basic rate is not separated, so it is uprated with the full +rate (as `overlapping_state_pension` also notes). So a reform to the full rates, or to the triple lock, moves only basic and new State Pension. A reform that sets a full rate to zero leaves additional From 69903500ea51a15415647d355eb6d9eba8713133 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:30:07 +0000 Subject: [PATCH 101/141] Update package version --- CHANGELOG.md | 9 +++++++++ changelog.d/2116.fixed.md | 1 - changelog.d/2499.fixed.md | 1 - changelog.d/721-director-2022.fixed.md | 1 - pyproject.toml | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) delete mode 100644 changelog.d/2116.fixed.md delete mode 100644 changelog.d/2499.fixed.md delete mode 100644 changelog.d/721-director-2022.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b2127ab22a..e90d87e04a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2.141.7] - 2026-10-21 + +### Fixed + +- - Added the 2022–23 annual employee National Insurance schedule for explicitly declared full-year category A directors in one employment, preserving ordinary employees and other years. +- - Corrected the JSA, ESA and DLA program registry variable mappings, narrowed Benefit Cap's parameter prefix to its own subtree, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. +- - Reject data-axis aggregate-impact comparisons when either measurement omits its Python version or machine identity. + + ## [2.141.6] - 2026-10-20 ### Fixed diff --git a/changelog.d/2116.fixed.md b/changelog.d/2116.fixed.md deleted file mode 100644 index 87f51f8729..0000000000 --- a/changelog.d/2116.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Corrected the JSA, ESA and DLA program registry variable mappings, narrowed Benefit Cap's parameter prefix to its own subtree, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. diff --git a/changelog.d/2499.fixed.md b/changelog.d/2499.fixed.md deleted file mode 100644 index 5cac9e7146..0000000000 --- a/changelog.d/2499.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Reject data-axis aggregate-impact comparisons when either measurement omits its Python version or machine identity. diff --git a/changelog.d/721-director-2022.fixed.md b/changelog.d/721-director-2022.fixed.md deleted file mode 100644 index 485e548b1d..0000000000 --- a/changelog.d/721-director-2022.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Added the 2022–23 annual employee National Insurance schedule for explicitly declared full-year category A directors in one employment, preserving ordinary employees and other years. diff --git a/pyproject.toml b/pyproject.toml index f446da3875..0df82f112f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.6" +version = "2.141.7" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 7b92389ed02f2d17ab320a5476ef57f6fb842a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:02:30 +0100 Subject: [PATCH 102/141] Move bus fares with the published fares index by area and year The engine held the FY2024-25 yield per fare-paying boarding in every later year and moved it only by a cap term measured against a fixed 2024 cap schedule. Outside London that lifted the 2025-26 yield 24.9%, while DfT's BUS0415a index for the same area rose 12.6% on the same April-to-March basis, cap rise included. Each area's yield now moves with its BUS0415a index (London, England outside London, Scotland), stored as published quarter-end values dated at the start of their quarter and day-weighted over 6 April to 5 April. After the last published quarter (March 2026) the index grows with the OBR CPI. Northern Ireland has no index, so its yield follows CPI. reference_fares.cap becomes the cap the index already reflects: the cap in force up to the last published quarter, held after it. Both schedules are read in the same year, so the cap term is zero while the index is published and adds the scheduled 2027 GBP 2 cap and its 2028 end after it. reference_year is removed with directions; benchmark_year names the benchmark year. Each area following its own index means the England total rises 7.6% in 2025-26, more than DfT's England index (6.4%), which weights outside London at about one half where receipts weight it at 61%. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2542.fixed.md | 1 + changelog.d/2542.removed.md | 1 + docs/engineering/bus-fares.md | 82 +++++++++++++++--- .../gov/dft/bus/benchmark_year.yaml | 15 ++++ .../gov/dft/bus/local_bus_fares_index.yaml | 60 +++++++++++++ .../gov/dft/bus/reference_fares/cap.yaml | 14 ++-- .../gov/dft/bus/reference_year.yaml | 13 --- .../policy/baseline/gov/dft/bus_journeys.yaml | 84 +++++++++++++++++-- .../tests/test_bus_fare_parameters.py | 64 ++++++++++++++ .../tests/test_bus_fare_reforms.py | 59 +++++++++++++ policyengine_uk/utils/parameters.py | 7 ++ .../gov/dft/person_bus_fare_spending.py | 44 +++++++--- 12 files changed, 393 insertions(+), 51 deletions(-) create mode 100644 changelog.d/2542.fixed.md create mode 100644 changelog.d/2542.removed.md create mode 100644 policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml create mode 100644 policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml delete mode 100644 policyengine_uk/parameters/gov/dft/bus/reference_year.yaml create mode 100644 policyengine_uk/tests/test_bus_fare_parameters.py diff --git a/changelog.d/2542.fixed.md b/changelog.d/2542.fixed.md new file mode 100644 index 0000000000..eba333999c --- /dev/null +++ b/changelog.d/2542.fixed.md @@ -0,0 +1 @@ +- Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. diff --git a/changelog.d/2542.removed.md b/changelog.d/2542.removed.md new file mode 100644 index 0000000000..d5821417ad --- /dev/null +++ b/changelog.d/2542.removed.md @@ -0,0 +1 @@ +- Removed `gov.dft.bus.reference_year`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`, and the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year. A reform naming the removed parameter fails with directions. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index 57547f47ac..403bebfa09 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -66,6 +66,47 @@ residents because non-residents also travel there. Scotland and Northern Ireland use the English other-local-bus trip rate as a proxy, as Microcosm does. These translations include interchanges and differences between operator boardings and resident trips; they are not counts of legs for a known trip. +`gov.dft.bus.benchmark_year` (2024) names the fiscal year these facts describe. + +## Fares by year + +In other years each area's yield moves with its published fares index: + +``` +yield(t) = benchmark_yield * index(t) / index(benchmark_year) +``` + +`gov.dft.bus.local_bus_fares_index` holds DfT's +[BUS0415a](https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue) +quarter-end values (March 2005 = 100) for London, England outside London and +Scotland, from the release to March 2026. Each value is dated at the start of +the quarter it ends, and `fiscal_year_blend` day-weights the quarters over +6 April to 5 April, the model's year. FY2024–25 outside London is therefore +(86 × 181.6 + 92 × 185.7 + 92 × 185.9 + 90 × 206.4 + 5 × 210.5) / 365 = 190.23, +within 0.2% of the mean of its four quarter ends (189.9). After the last +published quarter the index grows with the OBR consumer price index each +January, as `private_rent_index` does. Northern Ireland has no published index, +so its yield grows with CPI from the benchmark year. Wales stays unpriced. + +| Area | FY2024–25 | FY2025–26 | FY2026–27 | FY2027–28 | +|---|---:|---:|---:|---:| +| London | 196.10 | 196.10 | 197.13 | 201.08 | +| England outside London | 190.23 | 214.18 | 219.09 | 223.48 | +| Scotland | 210.63 | 229.94 | 235.90 | 240.63 | + +FY2025–26 and earlier are published; later years are CPI projections. The +index records prices paid, so it already contains each cap in force while it +is published, including the rise to £3 in January 2025 (outside London 185.9 +in December 2024, 206.4 in March 2025). The cap term below adds only what the +index cannot contain. + +Each area follows its own index, so the England total moves with the +receipts-weighted mix of the London and outside-London indices: +7.6% in +FY2025–26. DfT's England index rose 6.4%. Its step from December 2024 to +March 2025 was 5.6%, against 11.0% outside London and none in London, so it +weights outside London at about one half, where receipts weight it at 61%. +A receipts target moved by the England index therefore sits below the model's +England total. ## Caps and the uncapped counterfactual @@ -80,23 +121,38 @@ The cap does not affect the London series, Scottish or Northern Irish fares. England outside London uses a representative participating-service assumption; route-level participation and local lower caps require caller-supplied fares. -The published yield already includes ticket discounts and the benchmark cap. -For an English fare-paying person's other-local-bus boardings, the model uses: +The published yield and index already include ticket discounts and the cap in +force. `gov.dft.bus.reference_fares.cap` records that cap: the cap in force up +to the last published quarter of the index (£3 from January 2025), held after +it. For an English fare-paying person's other-local-bus boardings, the model +uses: ``` -current_yield = max(0, benchmark_yield + single_share * - (annual_min(uncapped_fare, current_cap) - - annual_min(uncapped_fare, reference_cap))) -spending = trips * boardings_per_trip * current_yield +yield(t) = max(0, benchmark_yield * index(t) / index(benchmark_year) + + single_share * + (annual_min(uncapped_fare, fares.cap, t) - + annual_min(uncapped_fare, reference_fares.cap, t))) +spending = trips * boardings_per_trip * yield(t) ``` +Both schedules are read in the same year. While the index is published they +agree and the cap term is zero, so the baseline is the published path. After +the last published quarter the term adds the scheduled changes: the £2 cap from +January 2027 and its end in 2028. A reform of `fares.cap` adds its own +difference from the reference. Leave `reference_fares` unchanged in reforms. +When a newly published quarter is added to the index, extend +`reference_fares.cap` with the cap then in force; +`policyengine_uk/tests/test_bus_fare_parameters.py` fails until the two +schedules agree up to the last published quarter. + `annual_min` caps the fare in each date interval, then weights the intervals from 6 April to 5 April, assuming uniform journeys through the year. It does -not cap a fare using the average cap. Both `fares` and `reference_fares` preserve -calendar dates. `reference_year` is 2024 and `reference_fares` holds the benchmark -schedule fixed when the policy cap changes. The source receipts use 1 April to -31 March; this approximation treats them as the corresponding model year's -benchmark. Annual Scenario changes apply to the full model fiscal year. +not cap a fare using the average cap. Both `fares` and `reference_fares` keep +calendar dates (their index files propagate `preserve_calendar_dates` to the +cap leaves), so a cap change part-way through a year applies from its date. +The source receipts use 1 April to 31 March; this approximation treats them as +the corresponding model year's benchmark. Annual Scenario changes apply to the +full model fiscal year. The default uncapped fare is £2.748019953976. This is an aggregate assumption, not an observed fare distribution. The FY2024–25 cap grant is £515,693,216.60. @@ -127,8 +183,8 @@ spending. Both default to one; they are neutral reform controls rather than historical index series. Keep the denominator positive. `ridership_index` defaults to one and multiplies journey-priced spending, leaving supplied journey inputs intact. The model includes no demand elasticity or automatic -ridership growth. Benchmark yields and trip translations stay nominally -constant after 2024 unless changed explicitly. Dataset CPI-uprated household +ridership growth. Trip translations stay constant after 2024 unless changed +explicitly; yields move with the fares index. Dataset CPI-uprated household bus totals do not determine prices where journeys are available. A proportional scenario can set `gov.dft.bus.fare_index` to 0.9 for a 10% diff --git a/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml new file mode 100644 index 0000000000..5139c967dc --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml @@ -0,0 +1,15 @@ +description: Fiscal year of the published bus fare benchmark. yield_per_boarding and + boardings_per_trip describe this year (FY2024-25 receipts and boardings), and the yield in + another model year is the benchmark yield times the local bus fares index in that year over + its value in this one. +values: + '0001-01-01': 2024 +metadata: + unit: year + period: year + label: bus fare benchmark year + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml new file mode 100644 index 0000000000..8f403c601d --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml @@ -0,0 +1,60 @@ +description: DfT local bus fares index (table BUS0415a, March 2005 = 100) for each area + whose bus fares the model prices from journeys. Each value is the quarter-end figure DfT + publishes, dated at the start of the quarter it ends. fiscal_year_blend day-weights the + quarters over each 6 April to 5 April model year. After the last published quarter the + index grows with the OBR consumer price index. The yield per fare-paying boarding in a + model year is the benchmark yield times this index in that year over its value in the + benchmark year. Northern Ireland has no published index. +metadata: + label: local bus fares index + propagate_metadata_to_children: true + fiscal_year_blend: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + reference: + - title: DfT bus statistics, table BUS0415a, local bus fares index by metropolitan area status + and country (release to March 2026) + href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics data tables, BUS04 costs, fares and revenue + href: https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue +LONDON: + 2023-01-01: 194.3 # quarter ending March 2023 + 2023-04-01: 194.3 # quarter ending June 2023 + 2023-07-01: 194.3 # quarter ending September 2023 + 2023-10-01: 194.3 # quarter ending December 2023 + 2024-01-01: 196.1 # quarter ending March 2024 + 2024-04-01: 196.1 # quarter ending June 2024 + 2024-07-01: 196.1 # quarter ending September 2024 + 2024-10-01: 196.1 # quarter ending December 2024 + 2025-01-01: 196.1 # quarter ending March 2025 + 2025-04-01: 196.1 # quarter ending June 2025 + 2025-07-01: 196.1 # quarter ending September 2025 + 2025-10-01: 196.1 # quarter ending December 2025 + 2026-01-01: 196.10649454164795 # quarter ending March 2026, unrounded in the DfT file +ENGLAND_OUTSIDE_LONDON: + 2023-01-01: 173.1 # quarter ending March 2023 + 2023-04-01: 175.6 # quarter ending June 2023 + 2023-07-01: 177.8 # quarter ending September 2023 + 2023-10-01: 178.8 # quarter ending December 2023 + 2024-01-01: 179.9 # quarter ending March 2024 + 2024-04-01: 181.6 # quarter ending June 2024 + 2024-07-01: 185.7 # quarter ending September 2024 + 2024-10-01: 185.9 # quarter ending December 2024 + 2025-01-01: 206.4 # quarter ending March 2025 + 2025-04-01: 210.5 # quarter ending June 2025 + 2025-07-01: 213.6 # quarter ending September 2025 + 2025-10-01: 214.3 # quarter ending December 2025 + 2026-01-01: 217.95569364121366 # quarter ending March 2026, unrounded in the DfT file +SCOTLAND: + 2023-01-01: 185.2 # quarter ending March 2023 + 2023-04-01: 203.1 # quarter ending June 2023 + 2023-07-01: 203.1 # quarter ending September 2023 + 2023-10-01: 203.1 # quarter ending December 2023 + 2024-01-01: 203.3 # quarter ending March 2024 + 2024-04-01: 207.7 # quarter ending June 2024 + 2024-07-01: 210.9 # quarter ending September 2024 + 2024-10-01: 211.3 # quarter ending December 2024 + 2025-01-01: 211.5 # quarter ending March 2025 + 2025-04-01: 228 # quarter ending June 2025 + 2025-07-01: 228.4 # quarter ending September 2025 + 2025-10-01: 228.4 # quarter ending December 2025 + 2026-01-01: 234.6832699716523 # quarter ending March 2026, unrounded in the DfT file diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml index 79092767e7..7ff2c77e18 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml @@ -1,18 +1,20 @@ -description: Fixed national cap schedule for the bus-fare yield benchmark. Read in reference_year; leave unchanged when reforming fares.cap. +description: National single bus fare cap that the published local bus fares index already + reflects. It follows gov.dft.bus.fares.cap up to the last published quarter of + gov.dft.bus.local_bus_fares_index and holds the cap then in force after it. The model adds + the effect of any difference between the two schedules, so the scheduled 2027 GBP 2 cap and + its end in 2028 enter the baseline, and a reform of fares.cap adds its own effect. Leave + this schedule unchanged in reforms; extend it when a newly published quarter is added to + the index. values: 0001-01-01: .inf 2023-01-01: 2 2025-01-01: 3 - 2027-01-01: 2 - 2028-01-01: .inf metadata: unit: currency-GBP period: year - label: benchmark single bus fare cap + label: single bus fare cap in the published fares index reference: - title: DfT evaluation of the two-pound bus fare cap, 2023 to 2024 href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap - title: DfT three-pound national bus fare cap, through December 2026 href: https://www.gov.uk/guidance/3-national-bus-fare-cap - - title: DfT two-pound bus fares from January to December 2027 - href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml deleted file mode 100644 index c166df37d5..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Fiscal year of the published bus-fare yield benchmark, used only to price - the reference single-fare cap. -values: - '0001-01-01': 2024 -metadata: - unit: year - period: year - label: reference year - reference: - - title: DfT bus statistics data tables, BUS01 and BUS05i - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, NTS and published-receipts pricing evidence - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml index 52d193adeb..61f2814ca9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -4,7 +4,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -23,7 +23,7 @@ - name: Case 2, pass holders pay nothing even when they make journeys. period: 2025 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -36,7 +36,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST bus_in_london_trips: 100 @@ -51,7 +51,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST local_bus_trips: 100 @@ -66,7 +66,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 @@ -85,7 +85,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -101,7 +101,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -116,7 +116,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SCOTLAND other_local_bus_trips: 100 @@ -126,3 +126,71 @@ gov.dft.bus.fares.cap: 0 output: person_bus_fare_spending: 200 + +# Default parameters. The yield moves from the FY2024-25 benchmark with DfT's +# BUS0415a fares index, day-weighted over 6 April to 5 April. Quarter-end +# values date from the start of their quarter: FY2024-25 for England outside +# London is (86 * 181.6 + 92 * 185.7 + 92 * 185.9 + 90 * 206.4 + 5 * 210.5) +# / 365 = 190.2282 and FY2025-26 is (86 * 210.5 + 92 * 213.6 + 92 * 214.3 + +# 95 * 217.9557) / 365 = 214.1797. Benchmarks: docs/engineering/bus-fares.md. +- name: Case 9, the 2024-25 benchmark prices journeys at the published yield. + period: 2024 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 100 trips * 1.12466920264 boardings * GBP 1.554716967657. + person_bus_fare_spending: 174.85 + +- name: Case 10, the 2025-26 yield follows the fares index, not a cap term. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 174.8542 * 214.1797 / 190.2282 (+12.6%). The cap is GBP 3 all year + # both in the index and in the cap schedule, so nothing is added. + person_bus_fare_spending: 196.87 + +- name: Case 11, London fares follow the flat London index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: LONDON + bus_in_london_trips: 100 + bus_pass_eligible: false + output: + # 100 * 2.369144880275 * GBP 1.017729905895 * 196.1017 / 196.1. + person_bus_fare_spending: 241.12 + +- name: Case 12, Scottish fares follow the Scottish index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SCOTLAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # FY2024-25 (86 * 207.7 + 92 * 210.9 + 92 * 211.3 + 90 * 211.5 + 5 * 228) + # / 365 = 210.6290; FY2025-26 (86 * 228 + 92 * 228.4 + 92 * 228.4 + 95 * + # 234.6833) / 365 = 229.9411. 100 * 2.145240985363 * GBP 2.599723222144 + # * 229.9411 / 210.6290. + person_bus_fare_spending: 608.84 + +- name: Case 13, Northern Ireland has no fares index, so its yield follows CPI. + period: 2025 + absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # 100 * 1.252853758464 * GBP 2.550929922065 * 1.034 (OBR CPI, 2025 over + # 2024, as the engine's index rounds it: 1.61059 / 1.55763). + person_bus_fare_spending: 330.46 diff --git a/policyengine_uk/tests/test_bus_fare_parameters.py b/policyengine_uk/tests/test_bus_fare_parameters.py new file mode 100644 index 0000000000..35c8616ad9 --- /dev/null +++ b/policyengine_uk/tests/test_bus_fare_parameters.py @@ -0,0 +1,64 @@ +"""Bus fare parameters agree with each other and with the facts they encode.""" + +import datetime +from pathlib import Path + +import pytest +import yaml + +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario +from policyengine_uk import Simulation + +BUS_PARAMETERS = Path(__file__).parents[1] / "parameters" / "gov" / "dft" / "bus" + + +def last_published_quarter(): + """The first day of the last quarter in the published fares index, and + the day after it ends.""" + raw = yaml.safe_load((BUS_PARAMETERS / "local_bus_fares_index.yaml").read_text()) + areas = {key: values for key, values in raw.items() if key.isupper()} + starts = {max(values) for values in areas.values()} + # DfT publishes every area's quarter in the same release. + assert len(starts) == 1, starts + (start,) = starts + month = start.month + 3 + end = datetime.date(start.year + month // 13, (month - 1) % 12 + 1, 1) + return start, end + + +def test_reference_cap_is_the_cap_the_published_index_reflects(): + start, end = last_published_quarter() + assert (start, end) == (datetime.date(2026, 1, 1), datetime.date(2026, 4, 1)) + bus = system.parameters.gov.dft.bus + changes = sorted( + { + datetime.date.fromisoformat(value.instant_str) + for schedule in (bus.fares.cap, bus.reference_fares.cap) + for value in schedule.values_list + } + ) + held = bus.reference_fares(start.isoformat()).cap + for day in changes + [start, end]: + reference = bus.reference_fares(day.isoformat()).cap + if day < end: + # Up to the last published quarter, the index reflects the cap + # in force. + assert reference == bus.fares(day.isoformat()).cap, day + else: + # After it, the index holds the cap of that quarter. + assert reference == held, day + + +def test_removed_reference_year_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.reference_year": {"2025": 2025}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.reference_year has been removed" in message + assert "gov.dft.bus.benchmark_year" in message + assert "gov.dft.bus.fares.cap" in message diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index 144411cefe..20b8dff842 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -46,6 +46,65 @@ def bus_dataset(region="SOUTH_EAST", journeys=True, zero=False): ) +def projected_dataset(region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100, 20)): + """A 2024 dataset that Simulation extends to later years, so journeys are + supplied in each year. A UKMultiYearDataset supplies only its own years.""" + person = pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 1], + "person_household_id": [1, 1], + "age": [30, 30], + "bus_in_london_trips": list(london_trips), + "other_local_bus_trips": list(other_trips), + "bus_pass_eligible": [False, False], + "local_bus_single_fare_share": [0.7, 0.7], + } + ) + household = pd.DataFrame( + { + "household_id": [1], + "region": [region], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [1_500.0], + "rent": [0.0], + "household_weight": [1.0], + "bus_fare_spending": [999.0], + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=household, + fiscal_year=2024, + ) + + +def test_england_fares_follow_each_area_index(): + # Trips set so 2024-25 spending is in the ratio of published receipts + # (BUS05ai: London GBP 1,347.43m, England outside London GBP 2,069.95m). + london = 1_347.434943 / (2.369144880275 * 1.017729905895) + outside = 2_069.953713 / (1.12466920264 * 1.554716967657) + simulation = Simulation( + dataset=projected_dataset(london_trips=(london, 0), other_trips=(0, outside)) + ) + total = { + year: simulation.calculate("bus_fare_spending", year)[0] + for year in (2024, 2025) + } + assert total[2024] == pytest.approx(1_347.434943 + 2_069.953713, rel=1e-5) + # Each area moves with its own BUS0415a index, FY2025-26 over FY2024-25: + # London 196.1017 / 196.1 and England outside London 214.1797 / 190.2282. + assert total[2025] == pytest.approx( + 1_347.434943 * 1.0000086 + 2_069.953713 * 1.1259092, rel=1e-5 + ) + # So England rises 7.6%, more than DfT's England index (205.616 / 193.297, + # +6.4%). Its December 2024 to March 2025 step is 5.6%, against 11.0% + # outside London and none in London, so it weights outside London at about + # one half where receipts weight it at 61%. + assert total[2025] / total[2024] == pytest.approx(1.0763, abs=1e-4) + + def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): simulation = Simulation( dataset=bus_dataset(), diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 66f93aa66a..63633ae65e 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -186,6 +186,13 @@ class RemovedParameter(NamedTuple): "income that is not qualifying income for Savings Credit (SPC Regs " "2002 reg 9), instead.", ), + "gov.dft.bus.reference_year": RemovedParameter( + "removed bus fare reference year", + "Bus fares now follow gov.dft.bus.local_bus_fares_index from the " + "benchmark year gov.dft.bus.benchmark_year, and the cap term compares " + "gov.dft.bus.fares.cap with gov.dft.bus.reference_fares.cap in the same " + "year. Change gov.dft.bus.fares.cap to model a cap.", + ), } diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py index 208e33512b..847440d2d1 100644 --- a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -6,10 +6,11 @@ class person_bus_fare_spending(Variable): label = "personal bus and coach fare spending" documentation = ( - "Fare-paying bus journeys priced at the published yield, with changes " - "in the single-fare cap applied only to single-ticket boardings in " - "England outside London. Without usable journey data, allocates " - "reported household spending by age. See docs/engineering/bus-fares.md." + "Fare-paying bus journeys priced at the published yield, moved by each " + "area's published fares index, with changes in the single-fare cap " + "applied only to single-ticket boardings in England outside London. " + "Without usable journey data, allocates reported household spending " + "by age. See docs/engineering/bus-fares.md." ) entity = Person definition_period = YEAR @@ -56,17 +57,40 @@ def formula(person, period, parameters): [p.boardings_per_trip.SCOTLAND, p.boardings_per_trip.NORTHERN_IRELAND], default=p.boardings_per_trip.ENGLAND_OUTSIDE_LONDON, ) + # Each area's benchmark yield moves with its published fares index. + # Northern Ireland has no index, so its yield moves with CPI. + benchmark = parameters(f"{int(p.benchmark_year)}-01-01").gov + index = p.local_bus_fares_index + benchmark_index = benchmark.dft.bus.local_bus_fares_index + cpi_growth = ( + parameters(period).gov.economic_assumptions.indices.obr.consumer_price_index + / benchmark.economic_assumptions.indices.obr.consumer_price_index + ) + london_yield = ( + p.yield_per_boarding.LONDON * index.LONDON / benchmark_index.LONDON + ) other_yield = select( [in_scotland, in_ni], - [p.yield_per_boarding.SCOTLAND, p.yield_per_boarding.NORTHERN_IRELAND], - default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON, + [ + p.yield_per_boarding.SCOTLAND + * index.SCOTLAND + / benchmark_index.SCOTLAND, + p.yield_per_boarding.NORTHERN_IRELAND * cpi_growth, + ], + default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON + * index.ENGLAND_OUTSIDE_LONDON + / benchmark_index.ENGLAND_OUTSIDE_LONDON, ) + # The index already reflects the cap in reference_fares. Add the + # effect of the cap differing from it in the same year: scheduled + # changes after the last published quarter, and reforms. + year = period.start.year uncapped = max_(person("local_bus_uncapped_single_fare", period), 0) current_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.fares, period.start.year + uncapped, parameters.gov.dft.bus.fares, year ) reference_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.reference_fares, int(p.reference_year) + uncapped, parameters.gov.dft.bus.reference_fares, year ) single_share = clip(person("local_bus_single_fare_share", period), 0, 1) other_yield = max_( @@ -75,9 +99,7 @@ def formula(person, period, parameters): 0, ) spending = ( - max_(london_trips, 0) - * p.boardings_per_trip.LONDON - * p.yield_per_boarding.LONDON + max_(london_trips, 0) * p.boardings_per_trip.LONDON * london_yield + max_(other_trips, 0) * other_boardings * other_yield ) spending *= ( From b0968773181ac15c3bb8d2970a609c5bc4ea1bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:21:18 +0100 Subject: [PATCH 103/141] Price bus fare cap changes from a fitted table of uncapped single fares One uncapped fare of GBP 2.748 for every single ticket made a GBP 3 cap worth nothing and priced a full year at GBP 2 at about GBP 0.70bn. DfT's evaluation of the GBP 2 cap puts that fare near the average of the tickets above GBP 2, which were less than half of single tickets. gov.dft.bus.uncapped_single_fares replaces it with three bands, each with its share of single tickets and mean uncapped fare at FY2024-25 prices, uprated with CPI: 55% at GBP 1.65, 30% at GBP 2.21 and 15% at GBP 3.87. The model caps each band under both schedules and weights by share. A supplied local_bus_uncapped_single_fare (now registered as a supplied input and CPI-uprated when datasets are extended) replaces the table for that person. scripts/fit_bus_cap_savings.py fits the table by least squares to public facts: the 2023-24 fare cap grant (GBP 280.9m against 278.95m), the January 2025 BUS0415a step (9.8% of the yield against 9 to 11%), the funding announced for the GBP 3 cap in 2025 (GBP 140.0m against "over GBP 150 million") and the GBP 2 cap in 2027 (GBP 411.6m against 400m), and the shape of DfT's Table 6. A full year at GBP 2 now saves about GBP 0.36bn a year at 2024-25 boardings and GBP 3 about GBP 0.14bn. uncapped_single_fare is removed with directions. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2542.fixed.md | 1 + changelog.d/2542.removed.md | 2 +- docs/engineering/bus-fares.md | 86 ++++++++---- policyengine_uk/data/uprating_indices.yaml | 1 + .../gov/dft/bus/uncapped_single_fare.yaml | 18 --- .../dft/bus/uncapped_single_fares/fare.yaml | 28 ++++ .../dft/bus/uncapped_single_fares/index.yaml | 9 ++ .../dft/bus/uncapped_single_fares/share.yaml | 27 ++++ .../policy/baseline/gov/dft/bus_journeys.yaml | 34 +++++ .../tests/test_bus_fare_parameters.py | 100 ++++++++++++++ .../tests/test_bus_fare_reforms.py | 105 ++++++++++++++- policyengine_uk/utils/bus_fares.py | 14 ++ policyengine_uk/utils/parameters.py | 8 ++ policyengine_uk/utils/supplied_inputs.py | 1 + .../gov/dft/local_bus_uncapped_single_fare.py | 10 +- .../gov/dft/person_bus_fare_spending.py | 34 +++-- scripts/fit_bus_cap_savings.py | 124 ++++++++++++++++++ 17 files changed, 541 insertions(+), 61 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml create mode 100644 policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml create mode 100644 policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml create mode 100644 policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml create mode 100644 scripts/fit_bus_cap_savings.py diff --git a/changelog.d/2542.fixed.md b/changelog.d/2542.fixed.md index eba333999c..99f62e1071 100644 --- a/changelog.d/2542.fixed.md +++ b/changelog.d/2542.fixed.md @@ -1 +1,2 @@ - Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. +- Cap changes are priced from a three-band table of uncapped single fares (`gov.dft.bus.uncapped_single_fares`), fitted to DfT's evaluation of the £2 cap, the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for the £3 cap in 2025 and the £2 cap in 2027, and uprated with CPI, instead of one £2.748 fare for every single ticket. A £3 cap now binds, and a full year at £2 against no cap saves about £0.36bn a year at 2024-25 boardings instead of £0.70bn. A supplied `local_bus_uncapped_single_fare` replaces the table for that person. diff --git a/changelog.d/2542.removed.md b/changelog.d/2542.removed.md index d5821417ad..918c987f03 100644 --- a/changelog.d/2542.removed.md +++ b/changelog.d/2542.removed.md @@ -1 +1 @@ -- Removed `gov.dft.bus.reference_year`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`, and the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year. A reform naming the removed parameter fails with directions. +- Removed `gov.dft.bus.reference_year` and `gov.dft.bus.uncapped_single_fare`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`; the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year; and cap changes are priced from `gov.dft.bus.uncapped_single_fares`. A reform naming a removed parameter fails with directions. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index 403bebfa09..170db43c46 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -27,8 +27,9 @@ false and the person pays fares even if eligible for a statutory concession. For fare-paying passengers, `local_bus_single_fare_share` is the share of boardings bought as singles, bounded to [0, 1]. Its fallback is 0.70, the 2024 NTS other-local-bus estimate. -An optional `local_bus_uncapped_single_fare` overrides the representative -uncapped fare described below. Negative quantities and fares price at zero. +An optional `local_bus_uncapped_single_fare` replaces the table of uncapped +single fares described below for that person. Negative quantities and fares +price at zero. On loading a UK `Simulation`, a supplied household `bus_fare_spending` moves to `bus_fare_spending_reported`. The stored total therefore cannot freeze the @@ -129,9 +130,9 @@ uses: ``` yield(t) = max(0, benchmark_yield * index(t) / index(benchmark_year) - + single_share * - (annual_min(uncapped_fare, fares.cap, t) - - annual_min(uncapped_fare, reference_fares.cap, t))) + + single_share * sum over bands b of share_b * + (annual_min(fare_b, fares.cap, t) - + annual_min(fare_b, reference_fares.cap, t))) spending = trips * boardings_per_trip * yield(t) ``` @@ -154,26 +155,61 @@ The source receipts use 1 April to 31 March; this approximation treats them as the corresponding model year's benchmark. Annual Scenario changes apply to the full model fiscal year. -The default uncapped fare is £2.748019953976. This is an aggregate assumption, -not an observed fare distribution. The FY2024–25 cap grant is £515,693,216.60. -With `F = B - C` outside London and single share `s = 0.70`, the assumption -solves `s * (u - annual_min(u, reference_cap)) = grant / F`. Since `2 < u < 3`, -the reference year has 270 days at £2 and 95 at `u`, giving: - -``` -u = 2 + grant / (F * 0.70 * 270/365) -``` - -Removing the cap therefore raises the benchmark yield from about £1.55 to -£1.94 at the representative share, matching the grant-addback calculation -in issue #1871. This assumes full grant pass-through and no demand response. -Individual shares can differ, so a dataset aggregate need not recover the -published grant. Caps at or above £2.75, including the £3 cap, have no effect -relative to uncapped travel under the default £2.748 representative fare. -The default has no fare distribution above these caps, so it cannot estimate -the savings those caps give to passengers with higher fares. Supply -person-specific `local_bus_uncapped_single_fare` values above a cap to model -its effect on those passengers. Very large reductions can reach the zero-yield +### Uncapped single fares + +What a cap saves depends on how uncapped single fares are spread, not on their +average. `gov.dft.bus.uncapped_single_fares` holds three bands, each with its +share of single tickets and its mean uncapped fare at FY2024–25 prices: + +| Band | Share | Mean fare | +|---|---:|---:| +| `LOW`, at or below £2 | 0.55 | £1.65 | +| `MIDDLE`, above £2 and up to £3 | 0.30 | £2.21 | +| `HIGH`, above £3 | 0.15 | £3.87 | + +The fares rise with the OBR CPI after FY2024–25 and the shares stay, so a fixed +cap saves more as fares rise. The model caps each band's mean fare under both +schedules and weights the bands by their shares, which must sum to one. With +the bands at their means, the table is exact for caps of £2 and £3 at +FY2024–25 prices and approximate for other caps; a cap below the low band's +mean needs more bands. + +`scripts/fit_bus_cap_savings.py` fits the table by least squares to the facts +below, each scaled by its tolerance. It searches shares in steps of one point +and fares in steps of one penny, keeps each band's mean at least 10% above its +lower edge, and sets the low band's fare so all singles average £2.15: + +| Fact | Source | Target | Table gives | +|---|---|---:|---:| +| A full year at £2, 2023–24 | Fare cap support, DfT BUS05bi | £278.95m ± 10% | £280.9m | +| Part of the December 2024 to March 2025 index step outside London due to the £3 cap | BUS0415a: +11.0%; neighbouring quarters 0.1% to 2.3% | 10% ± 1 point of the yield | 9.8% | +| The £3 cap in 2025 | ["over £150 million"](https://www.gov.uk/government/speeches/national-bus-fare-cap) | £150m ± 20% | £140.0m | +| The £2 cap in 2027 | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £411.6m | +| Mean pre-cap price of single tickets | [DfT evaluation](https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap), Table 6 | £2.15 ± £0.10 | £2.15 | +| Share of single tickets above £2 | Table 6: £0.32 / (£2.73 − £2.00) | 0.44 ± 0.05 | 0.45 | +| Their mean pre-cap price | Table 6 | £2.73 ± £0.10 | £2.76 | + +The costs use the 0.70 default single share and fare-paying boardings outside +London (BUS01 journeys less BUS01c concessionary journeys: 1,281m in 2023–24 +and 1,331m in 2024–25, which also stands for 2025 and 2027), with fares moved +to each year by CPI. Table 6 compares January to October 2022 with the same +months of 2023 and is read at face value, for the table's shape. Deflated to +2022 prices it would put the 2023–24 saving about a third above the grant. +Possible reasons, not checked, are that the grant pays operators for revenue +lost net of the journeys the cap added, and that Table 6 covers the larger +participating operators only. The two announced figures are budgets; the £2 +cap's first ten months paid £210m against £245m planned. The table is a fitted +central estimate, not an observed fare distribution. + +At FY2025–26 prices the table saves £0.39 per single ticket for a full year at +£2 against no cap, and £0.15 at £3: about £0.36bn and £0.14bn a year at +2024–25 boardings. The single £2.748 fare this replaced saved £0.75 at £2 +(about £0.70bn) and nothing at £3. + +A dataset or calculator can supply `local_bus_uncapped_single_fare` for each +person. Where it is supplied, that person's single tickets cost that one fare +before the cap, and the table is not used. Single-year datasets extended by +`Simulation` uprate it with CPI. Very large reductions can reach the zero-yield floor. ## Other reform controls and limits diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ed2d334e12..abac27042a 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -65,6 +65,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - income_support_reported - jsa_contrib_reported - jsa_income_reported +- local_bus_uncapped_single_fare - maintenance_expenses - maternity_allowance_reported - miscellaneous_consumption diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml deleted file mode 100644 index 1730a733c3..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: Representative uncapped single fare inferred from the 2024-25 fare-cap grant, - fare-paying boardings and a 70 percent single-ticket share. Assumes no demand response - and full grant pass-through. This is a modelling assumption, not an observed individual - fare. Caps at or above GBP 2.75, including the GBP 3 cap, have no effect relative to - uncapped travel with this default GBP 2.748 fare. Supply person-specific - local_bus_uncapped_single_fare inputs to represent fares above those caps. - See docs/engineering/bus-fares.md. -values: - '2024-01-01': 2.748019953976 -metadata: - unit: currency-GBP - period: year - label: assumed uncapped single bus fare - reference: - - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, published-fact extraction and pricing derivation - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml new file mode 100644 index 0000000000..ae91b40374 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml @@ -0,0 +1,28 @@ +description: Mean uncapped single bus fare in England outside London in each band, at + FY2024-25 prices and uprated with the OBR consumer price index after that, so a fixed cap + saves more as fares rise. +metadata: + unit: currency-GBP + period: year + label: mean uncapped single bus fare by band + propagate_metadata_to_children: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index (release to March 2026) + href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 1.65 +MIDDLE: + '2024-01-01': 2.21 +HIGH: + '2024-01-01': 3.87 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml new file mode 100644 index 0000000000..b1efbc0cdf --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml @@ -0,0 +1,9 @@ +description: Uncapped single bus fares in England outside London in three bands, each with + its share of single tickets and its mean fare. The model prices a change in the national + single fare cap as the change in the mean capped fare over the bands, for the single-ticket + share of fare-paying boardings. The table is fitted to DfT's evaluation of the GBP 2 cap, + the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for + the GBP 3 cap in 2025 and the GBP 2 cap in 2027. See docs/engineering/bus-fares.md and + scripts/fit_bus_cap_savings.py. +metadata: + label: uncapped single bus fares diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml new file mode 100644 index 0000000000..2ba23e8153 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml @@ -0,0 +1,27 @@ +description: Share of single bus tickets in England outside London in each band of uncapped + fare at FY2024-25 prices. LOW is at or below GBP 2, MIDDLE above GBP 2 and up to GBP 3, and + HIGH above GBP 3. The shares must sum to one. +metadata: + unit: /1 + period: year + label: share of single tickets by uncapped fare band + propagate_metadata_to_children: true + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index (release to March 2026) + href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 0.55 +MIDDLE: + '2024-01-01': 0.3 +HIGH: + '2024-01-01': 0.15 diff --git a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml index 61f2814ca9..791dba5f6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -194,3 +194,37 @@ # 100 * 1.252853758464 * GBP 2.550929922065 * 1.034 (OBR CPI, 2025 over # 2024, as the engine's index rounds it: 1.61059 / 1.55763). person_bus_fare_spending: 330.46 + +# Cap changes use the table of uncapped single fares in +# gov.dft.bus.uncapped_single_fares: 55% of singles at GBP 1.65, 30% at +# GBP 2.21 and 15% at GBP 3.87 at FY2024-25 prices, uprated with OBR CPI +# (FY2025-26 x 1.61059 / 1.55763 = 1.0340). Case 10's passenger has 100 trips +# * 1.12466920264 boardings * 0.7 single share = 78.7268 single boardings, at +# a GBP 196.87 baseline. +- name: Case 14, without a cap in 2025-26 the singles above GBP 3 pay in full. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: .inf + output: + # Only the high band (3.87 * 1.0340 = 4.0016) is above GBP 3: + # 196.87 + 78.7268 * 0.15 * 1.0016. + person_bus_fare_spending: 208.70 + +- name: Case 15, a GBP 2 cap in 2025-26 saves the middle and high bands. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: 2 + output: + # Middle band 2.21 * 1.0340 = 2.2851 falls to 2; the high band falls + # from 3 to 2: 196.87 - 78.7268 * (0.30 * 0.2851 + 0.15 * 1). + person_bus_fare_spending: 178.33 diff --git a/policyengine_uk/tests/test_bus_fare_parameters.py b/policyengine_uk/tests/test_bus_fare_parameters.py index 35c8616ad9..511082de76 100644 --- a/policyengine_uk/tests/test_bus_fare_parameters.py +++ b/policyengine_uk/tests/test_bus_fare_parameters.py @@ -50,6 +50,92 @@ def test_reference_cap_is_the_cap_the_published_index_reflects(): assert reference == held, day +BANDS = ("LOW", "MIDDLE", "HIGH") +SINGLE_SHARE = 0.70 +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 + + +def cpi(year): + indices = system.parameters.gov.economic_assumptions.indices.obr + return indices.consumer_price_index(f"{year}-01-01") + + +def bands(year=2024): + """(share, fare) of each band, as the model reads them in a year.""" + table = system.parameters.gov.dft.bus.uncapped_single_fares + at = f"{year}-01-01" + return [ + (table.share.children[band](at), table.fare.children[band](at)) + for band in BANDS + ] + + +def saving(cap, year=2024, price_ratio=1.0): + """Mean saving per single ticket from a cap held all year.""" + return sum(share * max(price_ratio * fare - cap, 0) for share, fare in bands(year)) + + +def test_uncapped_fare_bands(): + (low_share, low), (middle_share, middle), (high_share, high) = bands() + assert low_share + middle_share + high_share == pytest.approx(1) + # Bands at FY2024-25 prices: at or below GBP 2, up to GBP 3, above GBP 3. + assert low <= 2 < middle <= 3 < high + # Fares move with CPI after FY2024-25; shares stay. + for year in (2025, 2027): + for (share, fare), (later_share, later_fare) in zip(bands(), bands(year)): + assert later_share == share + assert later_fare == pytest.approx(fare * cpi(year) / cpi(2024)) + + +def test_table_matches_dft_cap_evaluation(): + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap and GBP 1.83 after (a GBP 0.32 + # saving); those above GBP 2 averaged GBP 2.73, which puts their share + # near 0.32 / 0.73 = 0.44. Read at face value, as the fit does. + table = bands() + above = [(share, fare) for share, fare in table if fare > 2] + above_share = sum(share for share, _ in above) + assert sum(share * fare for share, fare in table) == pytest.approx(2.15, abs=0.03) + assert above_share == pytest.approx(0.44, abs=0.05) + assert sum(share * fare for share, fare in above) / above_share == pytest.approx( + 2.73, abs=0.05 + ) + assert saving(2) == pytest.approx(0.32, abs=0.03) + + +def test_table_matches_january_2025_index_step(): + # BUS0415a outside London rose 11.0% from December 2024 to March 2025, + # when the cap rose from GBP 2 to GBP 3; neighbouring quarters moved 0.1% + # to 2.3%. The cap's part, 9% to 11% of the yield at the December 2024 + # index level (1.554716967657 * 185.9 / 190.2282). + december_yield = 1.554716967657 * 185.9 / 190.2282 + step = SINGLE_SHARE * (saving(2) - saving(3)) / december_yield + assert 0.09 <= step <= 0.11 + + +def test_table_matches_2023_24_fare_cap_grant(): + # BUS05bi fare cap support for 2023-24, a full year at GBP 2: GBP 278.95m, + # over 1,281m fare-paying boardings. Fares at 2023-24 prices. + grant = ( + SINGLE_SHARE + * FARE_PAYING_2023_24 + * saving(2, price_ratio=cpi(2023) / cpi(2024)) + ) + assert grant == pytest.approx(278_951_028, rel=0.15) + + +def test_table_matches_announced_cap_funding(): + # Over GBP 150m for the GBP 3 cap in 2025 (DfT written statement, 29 + # October 2024) and GBP 400m for the GBP 2 cap in 2027 (DfT, 22 July 2026), + # at the 2024-25 number of fare-paying boardings. + cap_3 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(3, year=2025) + cap_2 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(2, year=2027) + assert cap_3 == pytest.approx(150e6, rel=0.20) + assert cap_2 == pytest.approx(400e6, rel=0.20) + + def test_removed_reference_year_gives_directions(): with pytest.raises(ValueError) as error: Simulation( @@ -62,3 +148,17 @@ def test_removed_reference_year_gives_directions(): assert "gov.dft.bus.reference_year has been removed" in message assert "gov.dft.bus.benchmark_year" in message assert "gov.dft.bus.fares.cap" in message + + +def test_removed_uncapped_single_fare_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.uncapped_single_fare": {"2025": 4}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.uncapped_single_fare has been removed" in message + assert "gov.dft.bus.uncapped_single_fares" in message + assert "local_bus_uncapped_single_fare" in message diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index 20b8dff842..1fd116473e 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -46,7 +46,9 @@ def bus_dataset(region="SOUTH_EAST", journeys=True, zero=False): ) -def projected_dataset(region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100, 20)): +def projected_dataset( + region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100, 20), fares=None +): """A 2024 dataset that Simulation extends to later years, so journeys are supplied in each year. A UKMultiYearDataset supplies only its own years.""" person = pd.DataFrame( @@ -61,6 +63,8 @@ def projected_dataset(region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100 "local_bus_single_fare_share": [0.7, 0.7], } ) + if fares is not None: + person["local_bus_uncapped_single_fare"] = list(fares) household = pd.DataFrame( { "household_id": [1], @@ -105,6 +109,100 @@ def test_england_fares_follow_each_area_index(): assert total[2025] / total[2024] == pytest.approx(1.0763, abs=1e-4) +# 100 trips by one passenger outside London: 112.47 boardings, 70% singles. +SINGLES = 100 * 1.12466920264 * 0.7 + + +def spending_2025(**parameter_changes): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0)), + scenario=Scenario( + parameter_changes={ + path: {2025: value} for path, value in parameter_changes.items() + } + ), + ) + return simulation.calculate("person_bus_fare_spending", 2025)[0] + + +def test_a_three_pound_cap_binds_and_cap_reforms_follow_the_table(): + cap = "gov.dft.bus.fares.cap" + baseline = spending_2025() + uncapped = spending_2025(**{cap: np.inf}) + two_pounds = spending_2025(**{cap: 2}) + assert uncapped > baseline > two_pounds + # FY2025-26 fares are the FY2024-25 table times CPI (1.034). Only the 15% + # at GBP 3.87 is above GBP 3; a GBP 2 cap also takes the 30% at GBP 2.21 + # down to GBP 2. + assert uncapped - baseline == pytest.approx( + SINGLES * 0.15 * (3.87 * 1.034 - 3), rel=1e-3 + ) + assert baseline - two_pounds == pytest.approx( + SINGLES * (0.30 * (2.21 * 1.034 - 2) + 0.15 * 1), rel=1e-3 + ) + + +def test_scheduled_caps_enter_the_projected_baseline(): + simulation = Simulation(dataset=projected_dataset(other_trips=(100, 0))) + bus = system.parameters.gov.dft.bus + index = bus.local_bus_fares_index.ENGLAND_OUTSIDE_LONDON + table = bus.uncapped_single_fares.fare + + def index_path(year): + # The fares index projected with CPI, which holds the GBP 3 cap. + ratio = index(f"{year}-01-01") / index("2024-01-01") + return 100 * 1.12466920264 * 1.554716967657 * ratio + + def fare(band, year): + return table.children[band](f"{year}-01-01") + + def spending(year): + return simulation.calculate("person_bus_fare_spending", year)[0] + + # 2026-27: GBP 2 for the last 95 days, from 1 January 2027. + middle = fare("MIDDLE", 2026) + change = -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 95 / 365 + assert spending(2026) == pytest.approx( + index_path(2026) + SINGLES * change, rel=1e-5 + ) + # 2027-28 runs to 5 April 2028, 366 days: GBP 2 for 270, then no cap + # from 1 January 2028 for 96. + middle, high = fare("MIDDLE", 2027), fare("HIGH", 2027) + change = ( + -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 270 + 0.15 * (high - 3) * 96 + ) / 366 + assert spending(2027) == pytest.approx( + index_path(2027) + SINGLES * change, rel=1e-5 + ) + # From 2028-29 there is no cap, so the 15% above GBP 3 pay in full. + change = 0.15 * (fare("HIGH", 2028) - 3) + assert spending(2028) == pytest.approx( + index_path(2028) + SINGLES * change, rel=1e-5 + ) + + +def test_a_supplied_uncapped_fare_replaces_the_table(): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0), fares=(4, 4)), + scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2025: 2}}), + ) + # The dataset's fare rises with CPI like other prices (2025: +3.4%). + assert simulation.calculate("local_bus_uncapped_single_fare", 2025)[ + 0 + ] == pytest.approx(4 * 1.034) + # Every single ticket costs GBP 4.14, so a GBP 2 cap in place of GBP 3 + # saves GBP 1 on each, against GBP 0.24 on average with the table. + reformed = simulation.calculate("person_bus_fare_spending", 2025)[0] + baseline = simulation.baseline.calculate("person_bus_fare_spending", 2025)[0] + assert baseline == pytest.approx(spending_2025(), rel=1e-6) + assert baseline - reformed == pytest.approx(SINGLES * 1, rel=1e-5) + + +def test_uncapped_fare_shares_must_sum_to_one(): + with pytest.raises(ValueError, match="sum to one"): + spending_2025(**{"gov.dft.bus.uncapped_single_fares.share.HIGH": 0.5}) + + def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): simulation = Simulation( dataset=bus_dataset(), @@ -114,7 +212,10 @@ def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): k = 1_850_509_887.26131 / (28.0685543170643 * 58_620_101) fare_paying = 1_850_509_887.26131 - 519_107_608.59444 benchmark = 2_069_953_713.42079 / fare_paying - uncapped_yield = benchmark + 515_693_216.6 / fare_paying + # In 2024-25 the cap was GBP 2 for 270 days and GBP 3 for 95. Without it, + # the 30% of singles at GBP 2.21 and the 15% at GBP 3.87 pay in full. + saving = 0.30 * 0.21 * 270 / 365 + 0.15 * (1.87 * 270 / 365 + 0.87 * 95 / 365) + uncapped_yield = benchmark + 0.7 * saving baseline_people = np.array([100, 20]) * k * benchmark reformed_people = np.array([100, 20]) * k * uncapped_yield assert simulation.baseline.calculate( diff --git a/policyengine_uk/utils/bus_fares.py b/policyengine_uk/utils/bus_fares.py index f52a6cfc7e..06e3f0c0f6 100644 --- a/policyengine_uk/utils/bus_fares.py +++ b/policyengine_uk/utils/bus_fares.py @@ -19,3 +19,17 @@ def annual_capped_fare(uncapped, schedule, year): np.minimum(uncapped, max(rates.cap, 0)) * share for rates, share in fiscal_year_segments(schedule, year) ) + + +def single_fare_cap_change(fares, shares, schedule, reference, year): + """Mean change in the price of a single ticket over the fiscal year when + the cap follows ``schedule`` instead of ``reference``. Each uncapped fare + is capped under both and weighted by its share of single tickets.""" + return sum( + share + * ( + annual_capped_fare(fare, schedule, year) + - annual_capped_fare(fare, reference, year) + ) + for fare, share in zip(fares, shares) + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 63633ae65e..7410e59535 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -193,6 +193,14 @@ class RemovedParameter(NamedTuple): "gov.dft.bus.fares.cap with gov.dft.bus.reference_fares.cap in the same " "year. Change gov.dft.bus.fares.cap to model a cap.", ), + "gov.dft.bus.uncapped_single_fare": RemovedParameter( + "removed single uncapped bus fare", + "Cap changes are priced from a table of uncapped single fares, " + "gov.dft.bus.uncapped_single_fares, with the share of single tickets " + "and the mean fare of each band (LOW, MIDDLE, HIGH). To price every " + "single ticket at one fare, set that band's share to 1 and the others " + "to 0, or supply local_bus_uncapped_single_fare for each person.", + ), } diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index a6a7600e6c..4007c9cbb6 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -44,6 +44,7 @@ "bus_in_london_trips", "other_local_bus_trips", "local_bus_trips", + "local_bus_uncapped_single_fare", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", diff --git a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py index 36a43e8967..02d8d2d0b4 100644 --- a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py +++ b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py @@ -3,11 +3,13 @@ class local_bus_uncapped_single_fare(Variable): label = "local bus uncapped single fare" - documentation = "Representative uncapped single fare for other-local-bus journeys, in pounds. Override to model a known fare; the default is an aggregate no-demand-response counterfactual." + documentation = ( + "Optional uncapped single fare for a person's other-local-bus journeys, " + "in pounds. When supplied, the person's single tickets are capped at " + "this one fare; otherwise the model uses the table of uncapped single " + "fares in gov.dft.bus.uncapped_single_fares." + ) entity = Person definition_period = YEAR value_type = float unit = GBP - - def formula(person, period, parameters): - return parameters(period).gov.dft.bus.uncapped_single_fare diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py index 847440d2d1..34ecb063d8 100644 --- a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.bus_fares import annual_capped_fare, has_bus_journeys +from policyengine_uk.utils.bus_fares import has_bus_journeys, single_fare_cap_change from policyengine_uk.utils.supplied_inputs import supplied_input @@ -83,19 +83,31 @@ def formula(person, period, parameters): ) # The index already reflects the cap in reference_fares. Add the # effect of the cap differing from it in the same year: scheduled - # changes after the last published quarter, and reforms. - year = period.start.year - uncapped = max_(person("local_bus_uncapped_single_fare", period), 0) - current_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.fares, year - ) - reference_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.reference_fares, year + # changes after the last published quarter, and reforms. Single + # tickets cost the person's supplied uncapped fare, or else follow + # the table of uncapped single fares. + supplied_fare = supplied_input(person, "local_bus_uncapped_single_fare", period) + if supplied_fare is None: + bands = p.uncapped_single_fares + shares = [bands.share.LOW, bands.share.MIDDLE, bands.share.HIGH] + if min(shares) < 0 or abs(sum(shares) - 1) > 1e-6: + raise ValueError( + "The shares in gov.dft.bus.uncapped_single_fares.share must " + "be non-negative and sum to one" + ) + fares = [bands.fare.LOW, bands.fare.MIDDLE, bands.fare.HIGH] + else: + shares, fares = [1], [supplied_fare] + cap_change = single_fare_cap_change( + [max_(fare, 0) for fare in fares], + shares, + parameters.gov.dft.bus.fares, + parameters.gov.dft.bus.reference_fares, + period.start.year, ) single_share = clip(person("local_bus_single_fare_share", period), 0, 1) other_yield = max_( - other_yield - + in_england * single_share * (current_single - reference_single), + other_yield + in_england * single_share * cap_change, 0, ) spending = ( diff --git a/scripts/fit_bus_cap_savings.py b/scripts/fit_bus_cap_savings.py new file mode 100644 index 0000000000..10780eb42f --- /dev/null +++ b/scripts/fit_bus_cap_savings.py @@ -0,0 +1,124 @@ +"""Fit the table of uncapped single bus fares to public facts. + +Prints the three-band table for gov.dft.bus.uncapped_single_fares (the share +of single tickets in each band and its mean uncapped fare, at FY2024-25 +prices) that best matches the facts below, and the value of each fact under +it. Fares move between years with the engine's OBR CPI index, as the +parameter's uprating does. See docs/engineering/bus-fares.md. + +Run with ``uv run python scripts/fit_bus_cap_savings.py``. +""" + +import numpy as np + +from policyengine_uk.system import system + +SINGLE_SHARE = 0.70 # gov.dft.bus.default_single_fare_share +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 +# The benchmark yield at the December 2024 BUS0415a level, just before the +# January 2025 step: 1.554716967657 * 185.9 / 190.2282. +YIELD_DECEMBER_2024 = 1.554716967657 * 185.9 / 190.2282 + +# Each fact: target and tolerance, relative (a share of the target) or absolute. +FACTS = { + # BUS05bi fare cap support, 2023-24, a full year at GBP 2 (GBP m). + "grant_2023_24": (278.951028, 0.10, "relative"), + # Part of the December 2024 to March 2025 BUS0415a step outside London + # (+11.0%; neighbouring quarters moved 0.1% to 2.3%) due to the cap rising + # from GBP 2 to GBP 3, as a share of the yield. + "january_2025_step": (0.10, 0.01, "absolute"), + # Funding of "over GBP 150 million" for the GBP 3 cap in 2025 (GBP m). + "cap_3_in_2025": (150, 0.20, "relative"), + # GBP 400 million backing the GBP 2 cap in 2027 (GBP m). + "cap_2_in_2027": (400, 0.20, "relative"), + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap; those above GBP 2 averaged GBP 2.73, + # and GBP 0.32 / (2.73 - 2.00) puts their share near 0.44. Used for shape, + # at face value. + "mean_single_fare": (2.15, 0.10, "absolute"), + "share_above_2": (0.44, 0.05, "absolute"), + "mean_above_2": (2.73, 0.10, "absolute"), +} + + +def price_ratios(): + cpi = system.parameters.gov.economic_assumptions.indices.obr.consumer_price_index + base = cpi("2024-01-01") + return {year: cpi(f"{year}-01-01") / base for year in (2023, 2025, 2027)} + + +def saving(shares, fares, cap, ratio=1.0): + """Mean saving per single ticket from a cap: sum of share * (fare - cap)+.""" + return sum(s * np.maximum(ratio * f - cap, 0) for s, f in zip(shares, fares)) + + +def facts(shares, fares, ratios): + low, middle, high = shares + above = middle + high + return { + "grant_2023_24": SINGLE_SHARE + * FARE_PAYING_2023_24 + / 1e6 + * saving(shares, fares, 2, ratios[2023]), + "january_2025_step": SINGLE_SHARE + * (saving(shares, fares, 2) - saving(shares, fares, 3)) + / YIELD_DECEMBER_2024, + "cap_3_in_2025": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 3, ratios[2025]), + "cap_2_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 2, ratios[2027]), + "mean_single_fare": sum(s * f for s, f in zip(shares, fares)), + "share_above_2": above, + "mean_above_2": (middle * fares[1] + high * fares[2]) / above, + } + + +def loss(values): + total = 0 + for name, (target, tolerance, kind) in FACTS.items(): + gap = values[name] / target - 1 if kind == "relative" else values[name] - target + total = total + (gap / tolerance) ** 2 + return total + + +def fit(): + """Grid search at one point of share and one penny of fare. Each band's + mean sits at least 10% above its lower edge (GBP 2.20 and GBP 3.30), and + the low band's fare closes the Table 6 mean of GBP 2.15.""" + ratios = price_ratios() + middle_fare, high_fare = np.meshgrid( + np.arange(220, 300) / 100, np.arange(330, 600) / 100, indexing="ij" + ) + best = (np.inf, None) + for middle in np.arange(10, 61) / 100: + for high in np.arange(2, 41) / 100: + low = round(1 - middle - high, 2) + if low <= 0: + continue + low_fare = (2.15 - middle * middle_fare - high * high_fare) / low + low_fare = np.round(low_fare, 2) + valid = (low_fare > 0) & (low_fare <= 2) + shares = (low, middle, high) + values = facts(shares, (low_fare, middle_fare, high_fare), ratios) + score = np.where(valid, loss(values), np.inf) + index = np.unravel_index(np.argmin(score), score.shape) + if score[index] < best[0]: + fares = (low_fare[index], middle_fare[index], high_fare[index]) + best = (score[index], (shares, fares)) + return best + + +if __name__ == "__main__": + score, (shares, fares) = fit() + print(f"loss {score:.3f}") + for band, share, fare in zip(("LOW", "MIDDLE", "HIGH"), shares, fares): + print(f"{band:7s} share {share:.2f} fare GBP {fare:.2f}") + values = facts(shares, fares, price_ratios()) + for name, (target, tolerance, kind) in FACTS.items(): + print(f"{name:18s} {values[name]:9.4f} target {target}") From d32849ce5564069e429c4b6a1f9d22d4a40567cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:31:20 -0400 Subject: [PATCH 104/141] Test a head who applies alone with her own State Pension and IIDB Under the merged code the head's State Pension counts through #2078's member mask and her IIDB through this PR's applicant-and-partner mask, while the father's IIDB in her benefit unit does not count. The case fails on each parent: 2,432.56 on f12016368 and 12,547.60 on main 76cfc9e79, against 14,980.16. Co-Authored-By: Claude Opus 5.5 --- ...cil_tax_reduction_prescribed_benefits.yaml | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index 458720e4e5..ea63047128 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -519,6 +519,62 @@ council_tax_reduction_head_applies_alone: true council_tax_reduction_applicable_income: 116.95 * 52 +- name: Where a household head applies alone, her own IIDB counts with her State Pension, and the father's does not + # Only the merged code counts both: the head's State Pension through the + # applicant's member mask, her IIDB through the applicant-and-partner mask. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + state_pension: 241.30 * 52 + iidb_reported: 46.78 * 52 + mother: + age: 45 + is_parent: true + is_household_head: false + father: + age: 46 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_pensioner: true + # State Pension 12,547.60, below the 12,570 personal allowance. + income_tax: [0, 0, 0, 0] + # State Pension 241.30 * 52 = 12,547.60, plus her IIDB at 20% + # 46.78 * 52 = 2,432.56: 14,980.16. The father's 12,162.80 IIDB is not + # hers (SI 2012/2885 Sch 1 para 11). + council_tax_reduction_applicable_income: 14_980.16 + # A single pensioner who reached pensionable age before April 2021: + # 256.00 * 52 = 13,312.00. + council_tax_reduction_applicable_amount: 13_312 + # 1,800 - 0.2 x (14,980.16 - 13,312.00) = 1,800 - 333.632 = 1,466.368. + # The couple are in her benefit unit, so the model charges no + # non-dependant deduction for them (#2017). + council_tax_benefit: 1_466.368 + - name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income period: 2026 absolute_error_margin: 0.01 From 60b09a4e5942c7cdb0ffa1bfe2fb5aad29992eb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:35:28 +0000 Subject: [PATCH 105/141] Update package version --- CHANGELOG.md | 8 ++++++++ changelog.d/2486.fixed.md | 1 - changelog.d/uprate-four-remaining-columns.fixed.md | 1 - pyproject.toml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/2486.fixed.md delete mode 100644 changelog.d/uprate-four-remaining-columns.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d5d012c1..b2127ab22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2.141.6] - 2026-10-20 + +### Fixed + +- - Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate twelve columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income), plus `pension_contributions_via_salary_sacrifice_pre_2015` (average earnings). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. +- - Limited the ordinary income-tax loss-cap pension uplift above earnings to relief-at-source contributions. + + ## [2.141.5] - 2026-10-20 ### Fixed diff --git a/changelog.d/2486.fixed.md b/changelog.d/2486.fixed.md deleted file mode 100644 index 18f02a0b54..0000000000 --- a/changelog.d/2486.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Limited the ordinary income-tax loss-cap pension uplift above earnings to relief-at-source contributions. diff --git a/changelog.d/uprate-four-remaining-columns.fixed.md b/changelog.d/uprate-four-remaining-columns.fixed.md deleted file mode 100644 index 81ee518f08..0000000000 --- a/changelog.d/uprate-four-remaining-columns.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate twelve columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income), plus `pension_contributions_via_salary_sacrifice_pre_2015` (average earnings). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. diff --git a/pyproject.toml b/pyproject.toml index d48ccc67f1..f446da3875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.5" +version = "2.141.6" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 81cbb7089e6b7a1aac215bebd107ed576c14a457 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:48:24 +0000 Subject: [PATCH 106/141] Update package version --- CHANGELOG.md | 13 +++++++++++++ changelog.d/2175.changed.md | 1 - changelog.d/2245.fixed.md | 1 - changelog.d/2454.fixed.md | 1 - changelog.d/fix-478-carbon-flow-docs.changed.md | 1 - pyproject.toml | 2 +- 6 files changed, 14 insertions(+), 5 deletions(-) delete mode 100644 changelog.d/2175.changed.md delete mode 100644 changelog.d/2245.fixed.md delete mode 100644 changelog.d/2454.fixed.md delete mode 100644 changelog.d/fix-478-carbon-flow-docs.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e90d87e04a..5acf098925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2.141.8] - 2026-10-21 + +### Changed + +- Clarify domestic and overseas carbon flows and household incidence, including imported emissions in the consumption footprint and the chart's 2019 DEFRA fallback intensities. +- Use public Core operations for UK parameter initialization and dated-cache clearing while preserving parameter and reform behavior. + +### Fixed + +- Updated recent residential SDLT purchase schedules and applied the inclusive first-time-buyer price maximum, retaining annual sampling and contract-protection limitations. +- - Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, retained actual supplied ordinary returns in the annual-allowance calculations, and preserved the childcare income tests in #2173. + + ## [2.141.7] - 2026-10-21 ### Fixed diff --git a/changelog.d/2175.changed.md b/changelog.d/2175.changed.md deleted file mode 100644 index 041e0d1f84..0000000000 --- a/changelog.d/2175.changed.md +++ /dev/null @@ -1 +0,0 @@ -Use public Core operations for UK parameter initialization and dated-cache clearing while preserving parameter and reform behavior. diff --git a/changelog.d/2245.fixed.md b/changelog.d/2245.fixed.md deleted file mode 100644 index 18d03b4389..0000000000 --- a/changelog.d/2245.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Updated recent residential SDLT purchase schedules and applied the inclusive first-time-buyer price maximum, retaining annual sampling and contract-protection limitations. diff --git a/changelog.d/2454.fixed.md b/changelog.d/2454.fixed.md deleted file mode 100644 index c4bf9c227a..0000000000 --- a/changelog.d/2454.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, retained actual supplied ordinary returns in the annual-allowance calculations, and preserved the childcare income tests in #2173. diff --git a/changelog.d/fix-478-carbon-flow-docs.changed.md b/changelog.d/fix-478-carbon-flow-docs.changed.md deleted file mode 100644 index 9d41be4104..0000000000 --- a/changelog.d/fix-478-carbon-flow-docs.changed.md +++ /dev/null @@ -1 +0,0 @@ -Clarify domestic and overseas carbon flows and household incidence, including imported emissions in the consumption footprint and the chart's 2019 DEFRA fallback intensities. diff --git a/pyproject.toml b/pyproject.toml index 0df82f112f..6634259432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.7" +version = "2.141.8" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From a5cbe7d5354726bae1df474ddcd1ef2422a17154 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:54:08 +0000 Subject: [PATCH 107/141] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/ctr-income-prescribed-benefits.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/ctr-income-prescribed-benefits.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acf098925..223763cccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.9] - 2026-10-21 + +### Fixed + +- Council Tax Reduction income now counts the applicant's and partner's industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). + + ## [2.141.8] - 2026-10-21 ### Changed diff --git a/changelog.d/ctr-income-prescribed-benefits.fixed.md b/changelog.d/ctr-income-prescribed-benefits.fixed.md deleted file mode 100644 index 8591acdafc..0000000000 --- a/changelog.d/ctr-income-prescribed-benefits.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Council Tax Reduction income now counts the applicant's and partner's industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). diff --git a/pyproject.toml b/pyproject.toml index 6634259432..ae4dfc7a98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.8" +version = "2.141.9" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 9dcd1cc2ef68e74ee1c245e5e9c188cfa7a38d59 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 21 Oct 2026 07:01:31 +0100 Subject: [PATCH 108/141] Merge-fix: AFIP earns no UC disabled child addition in the young-person cap test Main (5092abf8e) now restricts the UC higher disabled child addition to UC Regs 2013 reg 24(2)(b) (DLA care highest, PIP daily living enhanced or Scottish equivalents, or blindness), so the 17-year-old's AFIP no longer adds 6,176.52. Update the test's pre-cap UC and cap reduction; the capped award (22,054.80) and the cap outcome are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../benefit_cap/benefit_cap_specified_benefits.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index 1d3d3660c0..082ac4d353 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -1111,7 +1111,10 @@ # 2013 reg 2), and reg 83(1)(c) excepts only where "a claimant is # receiving" it. The 17-year-old is a qualifying young person, not a # claimant, so the family's Universal Credit is capped at the London - # family rate of 25,323 a year. + # family rate of 25,323 a year. AFIP also earns no disabled child + # addition (reg 24(2) lists DLA, PIP and the Scottish equivalents, and + # blindness for the higher rate), so the maximum amount is the standard + # allowance, three child elements and the 30,000 housing costs element. period: 2026 input: people: @@ -1140,8 +1143,9 @@ is_uc_benefit_cap_exempt_specified_benefit: false is_uc_benefit_cap_exempt: false uc_benefit_cap: 25_323 - universal_credit_pre_benefit_cap: 52_792.44 - uc_benefit_cap_reduction: 30_737.64 + uc_disability_elements: 0 + universal_credit_pre_benefit_cap: 46_615.92 + uc_benefit_cap_reduction: 24_561.12 universal_credit: 22_054.80 absolute_error_margin: 1 From 68ce6a6fbc6d9e4071457f002fc70c68b82763be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:15:35 +0000 Subject: [PATCH 109/141] Update package version --- CHANGELOG.md | 11 +++++++++++ changelog.d/pc-earnings-disregard.fixed.md | 1 - changelog.d/pc-earnings-tax-reuse.changed.md | 1 - pyproject.toml | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/pc-earnings-disregard.fixed.md delete mode 100644 changelog.d/pc-earnings-tax-reuse.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 223763cccc..7058893897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2.141.10] - 2026-10-21 + +### Changed + +- - Reuse Pension Credit earnings tax calculations when checking Universal Credit housing contribution exemptions. + +### Fixed + +- Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit (including working tax credit with a disability element) or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at net earnings. Employed earnings are net of the income tax they add; self-employed earnings, in Pension Credit income too, are net of the notional tax and contributions of the Computation of Earnings Regulations 1996 reg. 14. The Housing Benefit savings-credit-only route now deducts only the higher Housing Benefit disregards. + + ## [2.141.9] - 2026-10-21 ### Fixed diff --git a/changelog.d/pc-earnings-disregard.fixed.md b/changelog.d/pc-earnings-disregard.fixed.md deleted file mode 100644 index 53ad66d2e6..0000000000 --- a/changelog.d/pc-earnings-disregard.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit (including working tax credit with a disability element) or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at net earnings. Employed earnings are net of the income tax they add; self-employed earnings, in Pension Credit income too, are net of the notional tax and contributions of the Computation of Earnings Regulations 1996 reg. 14. The Housing Benefit savings-credit-only route now deducts only the higher Housing Benefit disregards. diff --git a/changelog.d/pc-earnings-tax-reuse.changed.md b/changelog.d/pc-earnings-tax-reuse.changed.md deleted file mode 100644 index 601aa2a6d9..0000000000 --- a/changelog.d/pc-earnings-tax-reuse.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Reuse Pension Credit earnings tax calculations when checking Universal Credit housing contribution exemptions. diff --git a/pyproject.toml b/pyproject.toml index ae4dfc7a98..ff72e06a6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.9" +version = "2.141.10" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 0fb7c528f6c1b88f74ea31e8065d3f3f55ea443b Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 21 Oct 2026 09:20:20 +0100 Subject: [PATCH 110/141] Restate list-valued parameters only through routes that accept lists The restatement property test drew any stated parameter and any route. Scenario.parameter_changes types its values as numbers and booleans, so a draw of the list-valued gov.dwp.universal_credit.means_test.capital.person_sources through that route failed Scenario validation before any check ran. The council tax parameters this branch adds shift the derandomised draws onto that case in the merge queue. List values now use the other routes, and an explicit example pins the list restatement through the reform dict. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_dated_reform_routes.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 21bdd923ec..7bfffd350d 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1927,12 +1927,27 @@ def restatements(draw): stop = start + datetime.timedelta( days=draw(st.integers(0, (ends - start).days - 1)) ) - route = draw(st.sampled_from(sorted(ROUTES))) + # Scenario.parameter_changes types its values as numbers and booleans, so + # a list-valued parameter (such as the Universal Credit capital sources) + # is restated through the other routes only. + routes = sorted(ROUTES) + if not isinstance(value, (int, float, bool)): + routes.remove("parameter_changes") + route = draw(st.sampled_from(routes)) return name, start, stop, value, route @PROPERTY_SETTINGS @given(change=restatements()) +@example( + change=( + "gov.dwp.universal_credit.means_test.capital.person_sources", + datetime.date(2013, 4, 29), + datetime.date(2013, 4, 29), + [], + "reform_dict", + ) +) def test_restating_a_parameters_own_value_changes_nothing(change): name, start, stop, value, route = change simulation = ROUTES[route](as_dict(name, start, stop, value)) From ddede0b61eb95e679bda09029d7ddf88b991304d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 05:00:51 -0400 Subject: [PATCH 111/141] Say which coverage the trading-loss example adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review of #2534 found that the derandomized draws do include below-limit earners, at £0 to £0.12 a year. What they lack is a real trading loss and earnings between a few pence and the limit. Comment only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_carers_allowance_earnings_limit_properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index 1613bc2003..146a82091e 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -95,7 +95,7 @@ def _over_limit(earnings): # described in _over_limit: gainfully employed. @example(pay=10_608.52, profit=0.0, pension_share=0.0) # A trading loss, with earnings below the limit: the derandomized draws have -# neither. +# no real loss and no earnings between a few pence and the limit. @example(pay=8_000.0, profit=-5_000.0, pension_share=0.1) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share From 9387f49258b104a138411c94a22a4c38c86534a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:52:37 +0100 Subject: [PATCH 112/141] Derive the UC benefit cap earnings threshold from the national living wage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universal Credit Regulations 2013 reg. 82(1)(a) sets the earnings exception each year as the pay for 16 hours a week at the national living wage, times 52 and divided by 12, with any fraction of a pound disregarded (reg. 6(1A)(za)). Before 1 April 2017 (SI 2017/138) it was a fixed £430 a month. The model held it at the 2025-26 figure of £10,152 a year for every year. Add uc_benefit_cap_earnings_exception_threshold, read by is_uc_benefit_cap_exempt_earnings, with parameters for the hours and the pre-2017 fixed amount. The threshold is now £9,516 in 2024-25 and £10,572 in 2026-27. Part of #2523; the threshold part of #1986. Co-Authored-By: Claude Opus 5.5 --- ...uc-benefit-cap-earnings-threshold.fixed.md | 1 + .../universal_credit/benefit_cap/README.md | 1 + .../benefit_cap/earnings_exception/README.md | 1 + .../fixed_monthly_amount.yaml | 17 +++ .../benefit_cap/earnings_exception/hours.yaml | 22 ++++ .../claims_as_single_person.yaml | 4 +- ..._cap_earnings_exception_earned_income.yaml | 16 ++- ...efit_cap_earnings_exception_threshold.yaml | 110 ++++++++++++++++++ .../gov/dwp/benefit_cap_by_scheme.yaml | 4 +- .../dwp/universal_credit/uc_trading_loss.yaml | 2 +- .../is_uc_benefit_cap_exempt_earnings.py | 10 +- ...enefit_cap_earnings_exception_threshold.py | 52 +++++++++ 12 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 changelog.d/uc-benefit-cap-earnings-threshold.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py diff --git a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md new file mode 100644 index 0000000000..f453205a73 --- /dev/null +++ b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md @@ -0,0 +1 @@ +- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md new file mode 100644 index 0000000000..e48bb342f4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md @@ -0,0 +1 @@ +# Benefit cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md new file mode 100644 index 0000000000..1cd731ff55 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md @@ -0,0 +1 @@ +# Earnings exception diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml new file mode 100644 index 0000000000..5a35f0314c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml @@ -0,0 +1,17 @@ +description: >- + Fixed monthly earnings threshold of the Universal Credit benefit cap's + earnings exception before 1 April 2017. Regulation 82(1)(a) as made lifted + the cap where the claimant's earned income, or a couple's combined earned + income, was at least this amount. From 1 April 2017 the threshold is the pay + for a number of hours a week at the national living wage instead. +values: + 2013-04-29: 430 +metadata: + unit: currency-GBP + period: month + label: Universal Credit benefit cap earnings exception fixed threshold + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a), as made + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml new file mode 100644 index 0000000000..29956f0ff4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml @@ -0,0 +1,22 @@ +description: >- + Weekly hours at the national living wage that set the earnings threshold of + the Universal Credit benefit cap's earnings exception. Regulation 82(1)(a) + lifts the cap where the claimant's earned income, or a couple's combined + earned income, is at least the pay for these hours at the hourly rate in + regulation 4 of the National Minimum Wage Regulations, converted to a monthly + amount by multiplying by 52 and dividing by 12. Fractions of a pound in that + monthly amount are disregarded (regulation 6(1A)(za)). Before 1 April 2017 the + threshold was a fixed monthly amount instead. +values: + 2017-04-01: 16 +metadata: + unit: hour + period: week + label: Universal Credit benefit cap earnings exception hours + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 6(1A)(za) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 regs. 1 and 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml index 66b8c4e5e0..2905d77c7b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml @@ -722,8 +722,8 @@ # "Couple" in the cap includes a single claimant's partner (reg. 78(2)). # 11,000 a year (916.67 a month) exceeds 16 hours at the national living # wage of 12.71 (NMW Regs 2015 reg. 4 from 1 April 2026): - # 12.71 x 16 x 52 / 12 = 881.23 a month. The model uses a fixed - # 10,152 a year; 11,000 clears both. + # 12.71 x 16 x 52 / 12 = 881.23, so 881 a month (UC Regs reg. 6(1A)(za)), + # or 10,572 a year. is_uc_benefit_cap_exempt_earnings: true # Housing Benefit has no earnings exception (HB Regs 2006 regs. # 75E-75F); with no working tax credit the HB cap applies. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml index ae03b52bdd..11ffa117c5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml @@ -5,15 +5,14 @@ # (reg 55(4)), and the deductions are the person's own relievable pension # contributions and the income tax and primary Class 1 contributions paid "in # respect of the employment" (reg 55(5)). Income the minimum income floor -# treats a person as having does not count (reg 82(4)). The threshold is the -# model's 10,152 a year (issue #1986). Unless stated otherwise, each family is +# treats a person as having does not count (reg 82(4)). The 2026 threshold is +# 10,572 a year (881 a month; issue #2523). Unless stated otherwise, each family is # a single claimant aged 40 outside London with an entered award of 18,000 # before the cap, so the cap of 14,753 takes 3,247 unless an exception applies. # Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 # https://www.legislation.gov.uk/uksi/2013/376/regulation/82 # https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 -# The existing model threshold is retained here; 11,000 and 10,000 also lie -# above and below the 2026 NLW-based threshold of 10,574.72 respectively. +# 11,000 and 10,000 lie above and below it. - name: Statutory sick pay is earned income for the earnings exception period: 2026 @@ -59,7 +58,7 @@ region: NORTH_EAST output: # 3,000 + 8,000 = 11,000, under the personal allowance and the primary - # threshold, so no tax or NI: 11,000 >= 10,152. Without the maternity + # threshold, so no tax or NI: 11,000 >= 10,572. Without the maternity # pay the wages alone, 3,000, are under the threshold. uc_individual_earned_income_before_mif: 11_000 is_uc_benefit_cap_exempt_earnings: true @@ -88,7 +87,7 @@ # covered by the personal allowance, leaving all the tax on the pension. # Reg 55(5)(b) requires tax in respect of employment to be deducted but # does not prescribe this allocation between wages and pension income. - # No NI on 11,000. 11,000 >= 10,152. Netting the 1,686 off the wages + # No NI on 11,000. 11,000 >= 10,572. Netting the 1,686 off the wages # would give 9,314, under the threshold. income_tax: 1_686 uc_income_tax_on_earnings: 0 @@ -114,7 +113,7 @@ region: NORTH_EAST output: # Reg 55(5)(a): 11,000 - 1,000 = 10,000, with no tax or NI, is under - # 10,152. The cap applies: 18,000 - 14,753 = 3,247. + # 10,572. The cap applies: 18,000 - 14,753 = 3,247. uc_individual_earned_income_before_mif: 10_000 is_uc_benefit_cap_exempt_earnings: false is_uc_benefit_cap_exempt: false @@ -158,8 +157,7 @@ region: NORTH_EAST output: # The reform includes basic income in UC earned income, so the shared - # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,152. - # The total also exceeds #1820's 2026 NLW-based earnings threshold. + # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,572. # Counting basic income in this test is new in this PR: main's formula # never counted it. Whether reg 82 should count it is an open question # for #1986 and #1820. The award of 25,000 is above the couple cap diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml new file mode 100644 index 0000000000..17e305f5e4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml @@ -0,0 +1,110 @@ +# The Universal Credit benefit cap's earnings threshold (UC Regs 2013 reg +# 82(1)(a)). From 1 April 2017 (SI 2017/138) it is the pay for 16 hours a week +# at the national living wage (NMW Regs 2015 reg 4), times 52 and divided by +# 12, with any fraction of a pound disregarded (reg 6(1A)(za)). Before then it +# was a fixed 430 a month. The model compares a year's earned income with +# twelve times the monthly threshold. +# Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.legislation.gov.uk/uksi/2013/376/regulation/6 +# https://www.legislation.gov.uk/uksi/2017/138/made +# +# The boundary cases are a single claimant aged 40 outside London with an +# entered award of 18,000 before the cap. Their earnings are under the +# personal allowance and the primary threshold, so no tax or NI comes off, and +# the cap of 14,753 takes 3,247 unless the exception applies. + +- name: Before April 2017 the threshold is a fixed 430 a month + period: 2016 + output: + # 430 x 12 = 5,160. + uc_benefit_cap_earnings_exception_threshold: 5_160 + +- name: The threshold in 2017-18 is 16 hours at the 7.50 national living wage + period: 2017 + output: + # 16 x 7.50 x 52 / 12 = 520.00 a month; x 12 = 6,240. + uc_benefit_cap_earnings_exception_threshold: 6_240 + +- name: The threshold in 2023-24 is 16 hours at the 10.42 national living wage + period: 2023 + output: + # 16 x 10.42 x 52 / 12 = 722.45, disregarding the fraction 722 a month; + # x 12 = 8,664. + uc_benefit_cap_earnings_exception_threshold: 8_664 + +- name: The threshold in 2024-25 is 16 hours at the 11.44 national living wage + period: 2024 + output: + # 16 x 11.44 x 52 / 12 = 793.17, so 793 a month; x 12 = 9,516. + uc_benefit_cap_earnings_exception_threshold: 9_516 + +- name: The threshold in 2025-26 is 16 hours at the 12.21 national living wage + period: 2025 + output: + # 16 x 12.21 x 52 / 12 = 846.56, so 846 a month; x 12 = 10,152. + uc_benefit_cap_earnings_exception_threshold: 10_152 + +- name: The threshold in 2026-27 is 16 hours at the 12.71 national living wage + period: 2026 + output: + # 16 x 12.71 x 52 / 12 = 881.23, so 881 a month; x 12 = 10,572. + uc_benefit_cap_earnings_exception_threshold: 10_572 + +- name: Earnings equal to the 2024-25 threshold lift the cap + period: 2024 + input: + age: 40 + employment_income: 9_516 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 9_516 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings a pound under the 2024-25 threshold leave the cap in place + period: 2024 + input: + age: 40 + employment_income: 9_515 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 + +- name: Earnings between the 2024-25 threshold and the 2025-26 figure lift the cap in 2024-25 + period: 2024 + input: + age: 40 + employment_income: 10_000 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,000 is at least 9,516. A threshold of 10,152 would leave the cap. + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings equal to the 2026-27 threshold lift the cap + period: 2026 + input: + age: 40 + employment_income: 10_572 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 10_572 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings between the 2025-26 figure and the 2026-27 threshold leave the cap in 2026-27 + period: 2026 + input: + age: 40 + employment_income: 10_571 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,571 is under 10,572, although it is above 10,152. + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml index 0f37b40de8..53e746bdf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml @@ -89,8 +89,8 @@ working_tax_credit: 0 output: # 2025-26: income tax 20% x (15,000 - 12,570) = 486; Class 1 NI 8% x - # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the model's - # 10,152 a year (846 a month). + # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the 2025-26 + # threshold of 10,152 a year (846 a month). is_uc_benefit_cap_exempt_earnings: true is_uc_benefit_cap_exempt: true is_housing_benefit_benefit_cap_exempt: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml index 9bb761603c..7627060033 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -104,7 +104,7 @@ uc_trading_losses_set_against_profits: 5_000 uc_individual_earned_income_before_mif: 7_000 uc_individual_earned_income: 20_000 - # Actual £7,000 is below £10,152, even though the MIF is £20,000. + # Actual £7,000 is below £10,572, even though the MIF is £20,000. is_uc_benefit_cap_exempt_earnings: false - name: Company earnings without the loss exceed the cap threshold with the same deductions diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py index ec478db213..872611b651 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py @@ -7,7 +7,9 @@ class is_uc_benefit_cap_exempt_earnings(Variable): label = "Exempt from the Universal Credit benefit cap because of earnings" documentation = ( "Whether the claimant's earned income, or a couple's combined earned " - "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a)). " + "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a), " + "uc_benefit_cap_earnings_exception_threshold), which follows the " + "national living wage each year. " "The measure is the assessed claimants' actual net earned income for " "Universal Credit: it includes statutory pay and deducts their own " "relievable pension contributions and tax and National Insurance on " @@ -27,7 +29,6 @@ class is_uc_benefit_cap_exempt_earnings(Variable): def formula(benunit, period, parameters): person = benunit.members - # Earnings exemption for UC (£846/month = £10,152/year) # Only check earned income, not the UC amount, to avoid a circular dependency. # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, # if the claimant is a member of a couple, the couple's combined @@ -39,5 +40,6 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( person("uc_individual_earned_income_before_mif", period) * claimant ) - earnings_threshold = 10_152 - return uc_earned >= earnings_threshold + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py new file mode 100644 index 0000000000..e71be2a417 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_earnings_exception_threshold(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit benefit cap earnings exception threshold" + documentation = ( + "The earned income over the year at or above which the Universal " + "Credit benefit cap does not apply (UC Regs 2013 reg. 82(1)(a)): " + "twelve times the monthly threshold. From 1 April 2017 the monthly " + "threshold is the pay for 16 hours a week at the national living " + "wage, the hourly rate in regulation 4 of the National Minimum Wage " + "Regulations, multiplied by 52 and divided by 12, with any fraction " + "of a pound disregarded (reg. 6(1A)(za)). Before then it was a fixed " + "£430 a month." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ), + dict( + title="Universal Credit Regulations 2013 reg. 6(1A)(za)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + ), + dict( + title="Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017", + href="https://www.legislation.gov.uk/uksi/2017/138/made", + ), + ] + + def formula(benunit, period, parameters): + exception = parameters( + period + ).gov.dwp.universal_credit.benefit_cap.earnings_exception + return exception.fixed_monthly_amount * MONTHS_IN_YEAR + + def formula_2017(benunit, period, parameters): + p = parameters(period) + exception = p.gov.dwp.universal_credit.benefit_cap.earnings_exception + # "The hourly rate set out in regulation 4" is the national living + # wage, the scale's top rate, as in uc_minimum_income_floor_gross. + national_living_wage = p.gov.hmrc.minimum_wage.non_apprentice.calc( + np.full(benunit.count, 200.0) + ) + monthly = np.floor( + national_living_wage * exception.hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + return monthly * MONTHS_IN_YEAR From 38cc9c44705569e1a78bef3c65ac7f95dd736fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:36:18 +0100 Subject: [PATCH 113/141] Give the old-rules reference the new earnings threshold, and pin a family in the gap The differential test's reference earnings exception still compared with a fixed 10,152 in 2026, so a working-age family earning between 10,152 and 10,572 would fail the invariant on the threshold, not the scheme split; the fixed examples happened to miss that range. The reference now reads uc_benefit_cap_earnings_exception_threshold, and a single claimant earning 10,400 joins the explicit examples. Co-Authored-By: Claude Opus 5.5 --- ...benefit_cap_lha_scheme_split_properties.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py index bc505ec5de..5976d26204 100644 --- a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py @@ -11,7 +11,8 @@ benefits. ``REFERENCE`` re-adds those formulas from the parent commit under ``reference_`` names, so one simulation computes the old and the new values for the same families. They are verbatim except for the names they -read, the inlined stopgap helpers and the earnings exception's dead code. +read, the inlined stopgap helpers, the earnings exception's dead code and its +threshold, which now follows the national living wage (#2523). The reference awards read the current ``uc_deductions``, which these single-household simulations leave at nil. @@ -189,7 +190,11 @@ def formula(benunit, period, parameters): - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) - return uc_earned >= 10_152 + # The old rule's fixed 10,152 is replaced by the current threshold + # (#2523), so this compares the split, not the threshold. + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) class reference_is_benefit_cap_exempt(Variable): @@ -523,6 +528,9 @@ def example_families(): region="LONDON", benunit_inputs=dict(income_support=24_000.0), ), + # A single claimant earning between the 2025-26 and 2026-27 earnings + # thresholds (10,152 and 10,572): no earnings exception in 2026. + family(adult_inputs(40, employment_income=10_400.0)), ] @@ -845,7 +853,8 @@ def test_examples_reach_the_cases(): mixed_age, hb_lcwra, hb_floor, - ) = range(8) + earner_in_gap, + ) = range(9) # Reg. 3(3): single for UC, a couple for HB, whatever the legacy claim. for i in (uc_family, legacy_family): assert v["uc_member_of_couple_claims_as_single_person"][i] @@ -903,3 +912,7 @@ def test_examples_reach_the_cases(): ) assert abs(v["housing_benefit"][hb_floor] - 0.5 * 52) < 0.01 assert v["reference_housing_benefit"][hb_floor] == 0 + # Earnings of 10,400 are under 2026's threshold of 10,572 (#2523), and + # the reference reads the same threshold, so neither is exempt. + assert not v["is_uc_benefit_cap_exempt"][earner_in_gap] + assert not v["reference_is_benefit_cap_exempt"][earner_in_gap] From 7bb21efd3ac8a84ff28733f5f0b3911926ac65a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:07:38 +0100 Subject: [PATCH 114/141] Use one relevant-earnings rule for pension relief and adjusted total income adjusted_total_income gave contributions relief up to its own earnings figure (taxable employment income plus trading profits before losses brought forward), while pension_contributions_relief and the annual allowance tests used pay before expenses plus trading profit before capital allowances and the trading allowance. Finance Act 2004 s. 189(2) counts employment income chargeable to income tax, here pay and statutory pay less allowable expenses but before the pension deductions the relief itself gives, and income chargeable under ITTOIA 2005 Part 2, which is after capital allowances and the trading allowance and before losses. Move that definition into utils/pensions.py (relevant_uk_earnings and trading_profits_before_losses) and use it, with pension_contributions_relief_amount, in all four places. The relief-at-source limit on the basic amount is unchanged from #2488. Part of #2523; follows #2488. Co-Authored-By: Claude Opus 5.5 --- changelog.d/relevant-uk-earnings.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 8 ++ .../hmrc/pensions/relevant_uk_earnings.yaml | 74 +++++++++++++++++++ ...est_pension_annual_allowance_properties.py | 11 ++- policyengine_uk/utils/pensions.py | 61 ++++++++------- .../reliefs/adjusted_total_income.py | 56 +++++--------- .../pensions/pension_contributions_relief.py | 8 +- 7 files changed, 152 insertions(+), 67 deletions(-) create mode 100644 changelog.d/relevant-uk-earnings.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml diff --git a/changelog.d/relevant-uk-earnings.fixed.md b/changelog.d/relevant-uk-earnings.fixed.md new file mode 100644 index 0000000000..7578cc84af --- /dev/null +++ b/changelog.d/relevant-uk-earnings.fixed.md @@ -0,0 +1 @@ +- Use one definition of relevant UK earnings (Finance Act 2004 s. 189(2)) for pension contribution relief, the annual allowance income tests and the pension deduction in adjusted total income: employment income after allowable expenses but before pension deductions, and trading profits after capital allowances and the trading allowance but before losses brought forward. Relief previously counted pay before expenses and trading profits before capital allowances and the trading allowance. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index ac881ae9de..14977e2b09 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -76,6 +76,14 @@ net pay. Threshold income deducts [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. +Ordinary pension relief, the ordinary `adjusted_total_income` and both income +tests take relevant UK earnings +([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)) from +one helper, `relevant_uk_earnings`: employment income chargeable to income +tax before deductions for pension contributions (pay and employer-paid taxable +statutory pay, less allowable expenses), and trading profits after capital +allowances and the trading allowance but before losses brought forward. + An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on actual contributions and earnings. It does not remove an actual supplied diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml new file mode 100644 index 0000000000..434ab77203 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml @@ -0,0 +1,74 @@ +# Relevant UK earnings (FA 2004 s.189(2)) set the annual limit on relief for a +# person's own pension contributions (s.190). They are employment income +# chargeable to income tax, after allowable expenses but before the pension +# deductions this relief gives, and trading income chargeable under ITTOIA +# 2005 Part 2, which is after capital allowances (CAA 2001 s.247) and the +# trading allowance (ITTOIA 2005 s.783AF) but before relief for losses brought +# forward (ITA 2007 s.83, a Step 2 deduction). pension_contributions_relief and +# the pension deduction in adjusted_total_income (ITA 2007 s.24A(8)) use the +# same figure. Each case is a person aged 45 in England with no other income. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/189 +# https://www.legislation.gov.uk/ukpga/2001/2/section/247 +# https://www.legislation.gov.uk/ukpga/2005/5/section/783AF + +- name: Capital allowances reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + capital_allowances: 4_000 + personal_pension_contributions: 9_000 + output: + # Part 2 profit is 10,000 - 4,000 = 6,000, above the 3,600 basic amount, + # so relief stops at 6,000 of the 9,000 paid. + pension_contributions_relief: 6_000 + # Total income 6,000 less the same 6,000 of relief. + adjusted_total_income: 0 + +- name: Allowable employment expenses reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 20_000 + employment_expenses: 5_000 + employee_pension_contributions_reported: 18_000 + output: + # Earnings after the 5,000 of expenses are 15,000, so a net pay + # arrangement relieves 15,000 of the 18,000 paid. + taxable_employment_income: 15_000 + pension_contributions_relief: 15_000 + adjusted_total_income: 0 + +- name: Losses brought forward do not reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + loss_relief: 6_000 + personal_pension_contributions: 8_000 + output: + # The loss reduces taxable trading income to 4,000 but not the Part 2 + # profit of 10,000, so all 8,000 is relieved. + taxable_self_employment_income: 4_000 + pension_contributions_relief: 8_000 + # Total income adds the 6,000 of brought-forward loss back: 10,000 less + # 8,000 of relief. + adjusted_total_income: 2_000 + +- name: A profit within the trading allowance is not relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 1_000 + self_employment_income: 800 + employee_pension_contributions_reported: 1_500 + output: + # The trading allowance fully relieves the 800 profit, so relevant UK + # earnings are the 1,000 of pay. Net pay has no basic-amount increase + # (s.191(7)), so relief stops at 1,000. + taxable_self_employment_income: 0 + pension_contributions_relief: 1_000 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 4df2f1f149..2e6c991df9 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -164,8 +164,17 @@ def _situation(people, year, overrides=None): return situation +# Without receipts supplied, the model reads a trading profit within the +# trading allowance (£1,000 from 2017-18, so in every year here) as fully +# relieved (ITTOIA 2005 s. 783AF). It is then not chargeable under Part 2 and +# so is not relevant UK earnings (FA 2004 s. 189(2)(b)). +TRADING_ALLOWANCE = 1_000 + + def _relevant_earnings(person): - return person["employment_income"] + max(0, person["self_employment_income"]) + profit = max(0, person["self_employment_income"]) + chargeable_profit = profit if profit > TRADING_ALLOWANCE else 0 + return person["employment_income"] + chargeable_profit def _with_contributions(people, contributions): diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 0d052156e3..41efaf46aa 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -3,16 +3,38 @@ from policyengine_uk.utils.supplied_inputs import supplied_input -def relevant_uk_earnings(person, period): - """FA 2004 s.189 earnings, including employer-paid taxable SSP/SMP. +def trading_profits_before_losses(person, period, hmrc): + """Trading profits chargeable under ITTOIA 2005 Part 2, before losses. - Statutory employment pay is held separately from wages. A trading loss - does not reduce those employment earnings (HMRC PTM044100). + Capital allowances are a trading expense (CAA 2001 s.247) and the trading + allowance replaces expenses (ITTOIA 2005 s.783AC), so both come off. + Losses brought forward (ITA 2007 s.83) are a Step 2 relief, which the + model deducts from trading profits (loss_relief); they stay in here. """ - return ( - person("employment_income", period) - + person("employment_benefits", period) - + np.maximum(0, person("self_employment_income", period)) + from policyengine_core.model_api import add + + deductions = [ + deduction + for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions + if deduction != "loss_relief" + ] + return np.maximum( + 0, person("self_employment_income", period) - add(person, period, deductions) + ) + + +def relevant_uk_earnings(person, period, hmrc): + """FA 2004 s.189(2) relevant UK earnings. + + Employment income chargeable to income tax, before the deductions for + pension contributions that this relief itself gives: pay and + employer-paid taxable SSP/SMP, which the model holds separately from + wages, less allowable expenses. Trading income is the profit chargeable + under ITTOIA 2005 Part 2, before losses. + A trading loss does not reduce employment earnings (HMRC PTM044100). + """ + return person("taxable_employment_income", period) + ( + trading_profits_before_losses(person, period, hmrc) ) @@ -46,11 +68,9 @@ def annual_allowance_net_income(person, period, parameters): relievable net pay. A supplied legacy return is actual pay/member payment; the computed NICs-only excess never enters income or contributions. """ - from policyengine_core.model_api import add - hmrc = parameters(period).gov.hmrc actual_member_contributions = person("pension_contributions", period) - actual_earnings = relevant_uk_earnings(person, period) + actual_earnings = relevant_uk_earnings(person, period, hmrc) supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. @@ -76,29 +96,18 @@ def annual_allowance_net_income(person, period, parameters): if not np.any((relievable_net_pay > 0) & (person("trading_loss", period) > 0)): return net_income - actual_taxable_pay = person("taxable_employment_income", period) income_before_loss = np.maximum( 0, person("net_income_before_trade_loss_relief", period) ) # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member # contributions, retaining its treatment of brought-forward trade losses # and pension relief. Brought-forward losses only reduce trading profits. - other_deductions = [ - deduction - for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = np.maximum( - 0, - person("self_employment_income", period) - - add(person, period, other_deductions), - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) relieved_contributions = pension_contributions_relief_amount( actual_member_contributions, - actual_taxable_pay + profits_before_carry_forward, + actual_earnings, person("personal_pension_contributions", period), person("age", period), hmrc, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 810a99cb83..0d4f98dd91 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import ( + pension_contributions_relief_amount, + relevant_uk_earnings, + trading_profits_before_losses, +) class adjusted_total_income(Variable): @@ -12,8 +17,9 @@ class adjusted_total_income(Variable): "relief the model deducts from trading profits) added back. Steps 3 " "and 4 deduct the pension contributions given relief (FA 2004 " "ss.192-194): the person's own contributions up to relevant UK " - "earnings (s.189(2): taxable employment income, including benefits " - "and statutory pay, and trading profits). The basic amount can " + "earnings (s.189(2): employment income, including statutory pay, " + "less allowable expenses but before pension deductions, and trading " + "profits before losses). The basic amount can " "increase that limit only through relief at source (ss.190(3), " "191(7)), which the model represents with gross personal pension " "contributions; employee contributions represent net pay. None is relieved from " @@ -39,45 +45,21 @@ class adjusted_total_income(Variable): ] def formula(person, period, parameters): - p = parameters(period).gov.hmrc - profits = person("self_employment_income", period) - other_deductions = [ - deduction - for deduction in p.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = max_( - 0, profits - add(person, period, other_deductions) - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + hmrc = parameters(period).gov.hmrc + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) total_income = ( person("net_income_before_trade_loss_relief", period) + losses_brought_forward_deducted ) - # Relevant UK earnings (FA 2004 s.189(2)): employment income chargeable - # to tax, including taxable benefits and statutory payments, and - # trading profits chargeable to tax. - relevant_earnings = ( - person("taxable_employment_income", period) + profits_before_carry_forward - ) - # FA 2004 ss.190(3),191(7): only actual relief-at-source contributions - # can use the basic-amount increase above relevant UK earnings. - basic_amount_increase = min_( - max_( - 0, - p.income_tax.reliefs.pension_contribution.basic_amount - - relevant_earnings, - ), + # The same relief as pension_contributions_relief, on the same + # relevant UK earnings (FA 2004 ss.189-191). + relieved_contributions = pension_contributions_relief_amount( + person("pension_contributions", period), + relevant_uk_earnings(person, period, hmrc), person("personal_pension_contributions", period), - ) - relief_limit = relevant_earnings + basic_amount_increase - under_age_limit = ( - person("age", period) < p.pensions.pension_contributions_relief_age_limit - ) - relieved_contributions = ( - min_(person("pension_contributions", period), relief_limit) - * under_age_limit + person("age", period), + hmrc, ) return max_(0, total_income - relieved_contributions) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py index d9afcef026..3ba60f7cd9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -17,8 +17,10 @@ class pension_contributions_relief(Variable): "above earnings (s. 191(7)); the model takes personal pension " "contributions as relief at source and employee contributions as a " "net pay arrangement. The annual allowance does not limit relief. " - "Relevant employment earnings include employer-paid taxable statutory " - "pay through employment_benefits. " + "Relevant UK earnings (s. 189(2)) are employment income, including " + "employer-paid taxable statutory pay, less allowable expenses but " + "before pension deductions, and trading profits after capital " + "allowances and the trading allowance but before losses. " "Contributions above it can be relieved here and separately attract " "the annual allowance charge (personal_pension_contributions_tax) " "under s. 227. The charge need not equal their tax relief." @@ -53,7 +55,7 @@ def formula(person, period, parameters): contributions = person("pension_contributions", period) return pension_contributions_relief_amount( contributions, - relevant_uk_earnings(person, period), + relevant_uk_earnings(person, period, hmrc), person("personal_pension_contributions", period), person("age", period), hmrc, From 628a51d1fdb462037d739d6facc5cdd07d83a8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:12 +0100 Subject: [PATCH 115/141] Uprate water and sewerage charges by country Ofwat's water bills series is the average household bill in England and Wales. Scottish Water sets Scotland's household charges within limits from the Water Industry Commission for Scotland, and they followed a different path: 8.8% in 2024-25, 9.9% in 2025-26 against Ofwat's 26%, and 8.67% in 2026-27. Northern Ireland has no domestic water charges. Add gov.economic_assumptions.yoy_growth.scottish_water.water_bills, with WICS's draft CPI + 2% cap for 2027-28 to 2029-30 until the Final Determination (29 October 2026), and uprate water_and_sewerage_charges by country in apply_single_year_uprating, as council tax is: England, Wales and unknown regions by Ofwat's series, Scotland by Scottish Water's, Northern Ireland not at all. The column was previously held flat to 2030 and then grown by the England and Wales index everywhere. It is kept out of the post-2030 fallback, reset with the other growth-factor inputs, and covered by the projection fingerprint. Fixes #2501. Part of #2523. Overlaps #1863, which lists the column under the Ofwat index: whichever lands second drops that line, and test_water_charges_are_uprated_once fails until it does. Co-Authored-By: Claude Opus 5.5 --- changelog.d/scottish-water-uprating.fixed.md | 1 + docs/book/assumptions/water-bills.md | 23 ++ policyengine_uk/data/economic_assumptions.py | 63 +++++ .../gov/economic_assumptions/yoy_growth.yaml | 41 +++ .../tests/test_projection_horizon.py | 31 ++- policyengine_uk/tests/test_water_uprating.py | 237 ++++++++++++++++++ 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 changelog.d/scottish-water-uprating.fixed.md create mode 100644 policyengine_uk/tests/test_water_uprating.py diff --git a/changelog.d/scottish-water-uprating.fixed.md b/changelog.d/scottish-water-uprating.fixed.md new file mode 100644 index 0000000000..4b15413462 --- /dev/null +++ b/changelog.d/scottish-water-uprating.fixed.md @@ -0,0 +1 @@ +- Uprate water and sewerage charges by country when a dataset is projected: England and Wales by Ofwat's average bills, Scotland by a new Scottish Water series (8.8% in 2024-25, 9.9% in 2025-26 and 8.67% in 2026-27, then the CPI + 2% cap in WICS's draft 2027-33 determination), and Northern Ireland, which has no domestic water charges, not at all. The charges were previously held flat to 2030 and then grown by the England and Wales series everywhere. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index bdc0027549..fce3e461a0 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -81,3 +81,26 @@ The model uses these year-on-year growth rates. Each year is the charging year s ```{warning} The 2027-2029 projections cannot be reproduced from the repository. They take the CCW table, which reflects Ofwat's PR24 *draft* determinations rather than the final ones, weight each company by customer numbers, and add a hardcoded CPIH path. The customer weights were in `ofwat_increases.csv`, which is gitignored and was never committed. ``` + +## Scotland and Northern Ireland + +Ofwat's bills cover England and Wales. When a dataset is projected forward, each household's `water_and_sewerage_charges` grows by its own country's series (`uprate_water_and_sewerage_charges` in `policyengine_uk/data/economic_assumptions.py`): + +- England and Wales, and households with an unknown region, take `ofwat.water_bills`. +- Scotland takes `scottish_water.water_bills`. Scottish Water sets household charges within limits from the [Water Industry Commission for Scotland](https://wics.scot/) (WICS), and every council tax band's charge is a fixed fraction of Band D, so each band rises by the same percentage. +- Northern Ireland has no domestic water charges, so any charge a dataset gives its households is held flat. + +| Year | Scottish Water growth | Source | +|------|-------------|--------| +| 2022 | 4.2% | [WICS, charges scheme 2022-23](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23) | +| 2023 | 5.0% | [WICS, charges scheme 2023-24](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24) | +| 2024 | 8.8% | [WICS, charges scheme 2024-25](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25) | +| 2025 | 9.9% | [Scottish Water, 28 January 2025](https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement) | +| 2026 | 8.67% | [Scottish Water, 22 January 2026](https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026) | +| 2027 | 3.9% | WICS Draft Determination 2027-33: CPI + 2%, with WICS's CPI assumption of 1.9% | +| 2028 | 3.9% | As above | +| 2029 | 4.1% | As above, with CPI at 2.1% | + +Each year is the charging year starting in April, so 2025 is 2025-26. Later years hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. + +`policyengine_uk/utils/create_ahc_deflator.py` still reads the England and Wales index. It is a standalone script that the model does not run. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index ce9419b944..6a665b65bf 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -56,6 +56,26 @@ "UNKNOWN": "england", } +# The growth series (under gov.economic_assumptions.yoy_growth) of each Region +# label's water and sewerage charges (#2501). Ofwat's series is the average +# household bill in England and Wales. Scottish Water's charges follow limits +# set by the Water Industry Commission for Scotland. Northern Ireland has no +# domestic water charges, so any a dataset gives its households are held flat. +# An unknown region takes the England and Wales series, which covers most +# households in Great Britain. +WATER_BILLS_GROWTH = "gov.economic_assumptions.yoy_growth" +_WATER_GROWTH_SERIES = { + **dict.fromkeys(_ENGLAND_REGIONS, "ofwat.water_bills"), + "WALES": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "NORTHERN_IRELAND": None, + "UNKNOWN": "ofwat.water_bills", +} +# Inputs uprated by country in this module rather than through +# uprating_indices.yaml, so the index their variable declares must not grow +# them a second time after LEGACY_PROJECTION_END_YEAR. +COUNTRY_UPRATED_INPUTS = frozenset({"water_and_sewerage_charges"}) + # The last year a single-year dataset is projected to: 2032, fiscal year # 2032-33. An OBR forecast runs five years past the fiscal year it is made in, @@ -102,6 +122,7 @@ def _unmapped_uprating_indices(uprating): if variable.uprating is not None and name not in mapped and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } @@ -133,9 +154,11 @@ def uprating_input_paths() -> list: """ uprating = _uprating_index_map() series = {value for value in _COUNCIL_TAX_GROWTH_SERIES.values() if value} + water_series = {value for value in _WATER_GROWTH_SERIES.values() if value} return sorted( set(uprating) | {f"{COUNCIL_TAX_GROWTH}.{name}" for name in series} + | {f"{WATER_BILLS_GROWTH}.{name}" for name in water_series} | { SOCIAL_RENT_GROWTH, PRIVATE_RENT_GROWTH, @@ -290,6 +313,12 @@ def apply_single_year_uprating( current_year = uprate_council_tax(current_year, previous_year, parameters) + # Water and sewerage charges are uprated by country. + + current_year = uprate_water_and_sewerage_charges( + current_year, previous_year, parameters + ) + # Rent is uprated by OBR forecasts/outturns by region. current_year = uprate_rent(current_year, previous_year, parameters) @@ -393,6 +422,39 @@ def uprate_council_tax( return current_year +def uprate_water_and_sewerage_charges( + current_year: UKSingleYearDataset, + previous_year: UKSingleYearDataset, + parameters: ParameterNode, +): + # Uprate water and sewerage charges for a single year dataset, by country. + + if "water_and_sewerage_charges" not in previous_year.household.columns: + return current_year + region = pd.Series(np.asarray(current_year.household["region"]).astype(str)) + unrecognised = set(region) - set(_WATER_GROWTH_SERIES) + if unrecognised: + raise ValueError( + "Cannot uprate water and sewerage charges for unrecognised regions: " + f"{sorted(unrecognised)}" + ) + growth = parameters.get_child(WATER_BILLS_GROWTH) + growth_by_region = {} + for name, series in _WATER_GROWTH_SERIES.items(): + rate = ( + None + if series is None + else growth.get_child(series)(current_year.time_period) + ) + growth_by_region[name] = 0.0 if rate is None else rate + growth_rates = region.map(growth_by_region).to_numpy() + + current_year.household["water_and_sewerage_charges"] = previous_year.household[ + "water_and_sewerage_charges" + ] * (1 + growth_rates) + return current_year + + def uprate_rent( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, @@ -587,6 +649,7 @@ def reset_growthfactor_uprating( variable for variables in uprating.values() for variable in variables } growthfactor_uprated_variables.update({"council_tax", "rent"}) + growthfactor_uprated_variables.update(COUNTRY_UPRATED_INPUTS) growthfactor_uprated_variables.update(_unmapped_uprating_indices(uprating)) first_year = min(dataset.datasets.keys()) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 7b34cae696..7f6e8f775c 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1252,6 +1252,47 @@ ofwat: href: https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/ - title: CCW, "Breakdown of water companies' bill increases 2025-30" (Ofwat PR24 draft determinations) href: https://www.ccw.org.uk/our-work/price-review/how-much-will-my-water-and-sewerage-bills-increase-by-2030/breakdown-of-water-companies-bill-increases-2025-30/ +scottish_water: + water_bills: + description: Year-on-year growth in Scottish Water's household water and sewerage charges. + # Scottish Water sets Scotland's household charges within limits from the + # Water Industry Commission for Scotland (WICS); Ofwat's series covers + # England and Wales only. Each value is the charging year starting in + # April, so 2025-01-01 holds 2025-26. + # - 2022-2026: the increase in household charges that Scottish Water + # announced and WICS approved for each year. Every council tax band's + # charge is a fixed fraction of Band D, so each band rises by the same + # percentage. + # - 2027-2029: the cap in WICS's Draft Determination for 2027-33 + # (consultation 30 June to 1 September 2026), CPI + 2% a year, with + # WICS's CPI assumption of 1.9%, 1.9% and 2.1%. WICS publishes the + # Final Determination on 29 October 2026; replace these with it. + # Later years hold the 2029-30 rate. + values: + 2022-01-01: 0.042 + 2023-01-01: 0.05 + 2024-01-01: 0.088 + 2025-01-01: 0.099 + 2026-01-01: 0.0867 + 2027-01-01: 0.039 + 2028-01-01: 0.039 + 2029-01-01: 0.041 + metadata: + unit: /1 + label: Scottish Water household charges growth + reference: + - title: WICS, Scottish Water's charges scheme 2022-23 (4.2%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23 + - title: WICS, Scottish Water's charges scheme 2023-24 (5%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24 + - title: WICS, Scottish Water's charges scheme 2024-25 (8.8%, letter of 30 January 2024) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25 + - title: Scottish Water, "Charges 2025 to 2026" (28 January 2025; 9.9%) + href: https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement + - title: Scottish Water, "Average water bills to rise £42 in 2026" (22 January 2026; 8.67%) + href: https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026 + - title: WICS, "WICS publishes 2027-33 Draft Determination" (CPI + 2% cap; Final Determination 29 October 2026) + href: https://wics.scot/latest/wics-publishes-2027-33-draft-determination finance_ni: domestic_rates: description: Growth in the average Northern Ireland domestic rates bill (regional rate plus district rate), used to project the domestic_rates dataset input. diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index bed29a66f2..2b6303b898 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -25,6 +25,7 @@ from policyengine_uk.data import UKSingleYearDataset from policyengine_uk.data import economic_assumptions from policyengine_uk.data.economic_assumptions import ( + COUNTRY_UPRATED_INPUTS, DATASET_UPRATING_EXCLUSIONS, LEGACY_PROJECTION_END_YEAR, PROJECTION_END_YEAR, @@ -54,6 +55,7 @@ and variable.value_type in (int, float) and name not in INDEX_OF and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } # These survey inputs already use FY2026-27 prices, so projection must not # apply their class-level CPI index a second time. @@ -79,6 +81,14 @@ "SCOTLAND": "scotland", "WALES": "wales", } +# Water and sewerage charges grow by country (#2501): Northern Ireland has +# none to grow. +WATER_SERIES = { + "LONDON": "ofwat.water_bills", + "NORTH_EAST": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "WALES": "ofwat.water_bills", +} def _dataset(amount=1_000.0, base_year=BASE_YEAR): @@ -110,6 +120,7 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): "tenure_type": [tenure for _, tenure in HOUSEHOLDS], "rent": amounts, "council_tax": amounts, + "water_and_sewerage_charges": amounts, }, } for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *ENERGY_VARIABLES): @@ -196,8 +207,9 @@ def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected): """Previously unmapped columns keep their old values through 2030, then grow by the same cumulative-index ratio Core used beyond that year. Columns uprating_indices.yaml now lists (the debts, ISAs, lodger and - boarder rent, gross receipts, salary sacrifice, water bills and service - charges) grow every year instead, so they are no longer fallbacks. + boarder rent, gross receipts, salary sacrifice and service charges), and + water bills, which grow by country, grow every year instead, so they are + no longer fallbacks. """ assert { "rail_usage", @@ -228,12 +240,25 @@ def test_reset_growthfactor_uprating_restores_unmapped_inputs(projected): reset = projected.copy() reset_growthfactor_uprating(reset) for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): - for variable in (*INDEX_OF, *FALLBACK_INDEX_OF): + for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *COUNTRY_UPRATED_INPUTS): base = _table(projected[BASE_YEAR], variable)[variable].to_numpy() current = _table(reset[year], variable)[variable].to_numpy() np.testing.assert_array_equal(current, base, err_msg=f"{variable}, {year}") +def test_water_charges_grow_by_country_every_projected_year(projected): + # Through the old horizon and after it, each country's own series grows + # the charge once a year; Northern Ireland's stays flat. + for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): + previous, current = projected[year - 1].household, projected[year].household + for i, (region, _) in enumerate(HOUSEHOLDS): + series = WATER_SERIES.get(region) + growth = 0.0 if series is None else GROWTH.get_child(series)(year) + assert current["water_and_sewerage_charges"].iloc[i] == pytest.approx( + previous["water_and_sewerage_charges"].iloc[i] * (1 + growth) + ), (region, year) + + def test_rent_and_council_tax_grow_every_projected_year(projected): for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): previous, current = projected[year - 1].household, projected[year].household diff --git a/policyengine_uk/tests/test_water_uprating.py b/policyengine_uk/tests/test_water_uprating.py new file mode 100644 index 0000000000..ffc7b610e8 --- /dev/null +++ b/policyengine_uk/tests/test_water_uprating.py @@ -0,0 +1,237 @@ +"""Dataset projection uprates water and sewerage charges by country (#2501). + +Ofwat's series is the average household bill in England and Wales. Scottish +Water's charges are set separately, within limits from the Water Industry +Commission for Scotland, and rose 9.9% in 2025-26 against Ofwat's 26%. +Northern Ireland has no domestic water charges, so any a dataset gives its +households are held flat. +""" + +import functools +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation, Simulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + LEGACY_PROJECTION_END_YEAR, + PROJECTION_END_YEAR, + _unmapped_uprating_indices, + _uprating_index_map, + extend_single_year_dataset, + uprate_water_and_sewerage_charges, + uprating_input_paths, +) +from policyengine_uk.variables.household.demographic.geography import Region + + +REGIONS = [region.name for region in Region] +UPRATED_YEARS = list(range(2023, PROJECTION_END_YEAR + 1)) +GROWTH = parameters.gov.economic_assumptions.yoy_growth +ENGLAND_AND_WALES = GROWTH.ofwat.water_bills +SCOTLAND = GROWTH.scottish_water.water_bills + + +@functools.cache +def _country_of_each_region(): + # The model's own region-to-country mapping (the `country` variable), a + # second path to the one the uprating code takes. + people = {f"person_{region}": {"age": 40} for region in REGIONS} + households = { + f"household_{region}": {"members": [f"person_{region}"], "region": region} + for region in REGIONS + } + sim = Simulation(situation={"people": people, "households": households}) + countries = sim.calculate("country", 2025) + return dict(zip(REGIONS, countries)) + + +def _expected_growth(region, year): + country = _country_of_each_region()[region] + if country == "NORTHERN_IRELAND": + return 0.0 + if country == "SCOTLAND": + return SCOTLAND(year) + # England, Wales and an unknown region take the England and Wales bills. + return ENGLAND_AND_WALES(year) + + +def _uprated(regions, charges, year): + previous_year = SimpleNamespace( + household=pd.DataFrame({"water_and_sewerage_charges": charges}, dtype=float) + ) + current_year = SimpleNamespace( + time_period=str(year), + household=pd.DataFrame( + {"region": regions, "water_and_sewerage_charges": [0.0] * len(charges)} + ), + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + return current_year.household["water_and_sewerage_charges"].to_numpy() + + +def test_scottish_water_growth_matches_its_sources(): + # Scottish Water's announced increases (8.8% approved by WICS on 30 + # January 2024; 9.9% on 28 January 2025; 8.67% on 22 January 2026). + assert SCOTLAND("2024-01-01") == pytest.approx(0.088) + assert SCOTLAND("2025-01-01") == pytest.approx(0.099) + assert SCOTLAND("2026-01-01") == pytest.approx(0.0867) + + +def test_each_country_takes_its_own_water_growth(): + charges = _uprated( + ["LONDON", "WALES", "SCOTLAND", "NORTHERN_IRELAND", "UNKNOWN"], + [500.0] * 5, + 2025, + ) + england_and_wales = 500 * (1 + ENGLAND_AND_WALES("2025-01-01")) + assert charges == pytest.approx( + [england_and_wales, england_and_wales, 500 * 1.099, 500.0, england_and_wales] + ) + + +@pytest.mark.parametrize("region", ["NORTHERN IRELAND", "ATLANTIS", ""]) +def test_unrecognised_regions_are_rejected(region): + with pytest.raises(ValueError, match="unrecognised regions"): + _uprated(["LONDON", region], [500.0, 500.0], 2025) + + +def test_a_dataset_without_water_charges_is_left_alone(): + previous_year = SimpleNamespace(household=pd.DataFrame({"rent": [1.0]})) + current_year = SimpleNamespace( + time_period="2025", household=pd.DataFrame({"region": ["SCOTLAND"]}) + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + assert "water_and_sewerage_charges" not in current_year.household + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + households=st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.floats(0, 5_000, allow_nan=False), + ), + min_size=1, + max_size=30, + ), + year=st.sampled_from(UPRATED_YEARS), + scale=st.floats(0, 10, allow_nan=False), +) +def test_water_uprating_properties(households, year, scale): + """For every Region label, any charge of zero or more and every + projected year: + + - the charge grows by its country's series, with the country taken from + the model's `country` variable: Ofwat's for England, Wales and unknown + regions, Scottish Water's for Scotland, and none for Northern Ireland; + - each household's result depends only on its own row; + - scaling every previous-year charge scales every result by the same + factor. + """ + regions, amounts = (list(column) for column in zip(*households)) + + charges = _uprated(regions, amounts, year) + + expected = [ + amount * (1 + _expected_growth(region, year)) for region, amount in households + ] + assert charges == pytest.approx(expected) + alone = [_uprated([region], [amount], year)[0] for region, amount in households] + assert charges == pytest.approx(alone) + scaled = _uprated(regions, [scale * amount for amount in amounts], year) + assert scaled == pytest.approx(scale * charges) + + +def test_water_charges_are_uprated_once(): + # The country uprating replaces a single index: listing the column in + # uprating_indices.yaml as well, or letting the index its variable + # declares grow it after LEGACY_PROJECTION_END_YEAR, would apply a second + # growth rate on top. + listed = {name for names in _uprating_index_map().values() for name in names} + assert "water_and_sewerage_charges" not in listed + assert "water_and_sewerage_charges" not in _unmapped_uprating_indices( + _uprating_index_map() + ) + + +def test_the_projection_fingerprint_covers_both_water_series(): + paths = uprating_input_paths() + assert "gov.economic_assumptions.yoy_growth.ofwat.water_bills" in paths + assert "gov.economic_assumptions.yoy_growth.scottish_water.water_bills" in paths + + +# A dataset with an unknown-region household cannot be projected forward until +# rent uprating handles Region.UNKNOWN (PolicyEngine/policyengine-uk#1985), so +# the dataset-level tests below use the twelve known regions. +KNOWN_REGIONS = [region for region in REGIONS if region != "UNKNOWN"] + + +def _one_household_per_known_region(charges): + ids = np.arange(1, len(KNOWN_REGIONS) + 1) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 40, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": KNOWN_REGIONS, + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + "water_and_sewerage_charges": charges, + } + ), + fiscal_year=2024, + ) + + +def _compounded(amount, region, year, base_year=2024): + return amount * np.prod( + [1 + _expected_growth(region, y) for y in range(base_year + 1, year + 1)] + ) + + +def test_extended_dataset_compounds_each_country_water_growth(): + # Every projected year chains its country's growth, through the old + # dataset horizon (2030) and the two years after it. + extended = extend_single_year_dataset( + _one_household_per_known_region(500.0), parameters, end_year=2032 + ) + + for year in range(2025, PROJECTION_END_YEAR + 1): + charges = extended[year].household["water_and_sewerage_charges"].to_numpy() + assert charges == pytest.approx( + [_compounded(500.0, region, year) for region in KNOWN_REGIONS] + ) + assert PROJECTION_END_YEAR > LEGACY_PROJECTION_END_YEAR + + +def test_microsimulation_gives_scotland_scottish_water_growth(): + sim = Microsimulation(dataset=_one_household_per_known_region(500.0)) + + charges = dict( + zip(KNOWN_REGIONS, sim.calculate("water_and_sewerage_charges", 2026).values) + ) + # 500 x 1.099 x 1.0867 in Scotland; Northern Ireland stays at 500. + assert charges["SCOTLAND"] == pytest.approx(500 * 1.099 * 1.0867) + assert charges["NORTHERN_IRELAND"] == 500.0 + assert charges["WALES"] == pytest.approx(charges["LONDON"]) + assert charges["LONDON"] == pytest.approx( + 500 + * (1 + ENGLAND_AND_WALES("2025-01-01")) + * (1 + ENGLAND_AND_WALES("2026-01-01")) + ) From ffbe1234f29dd41d8b3a517d32947d5ea3a8848c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:28:28 +0100 Subject: [PATCH 116/141] Read each water series by its path; cover water in the fingerprint test; document growth after 2032 The fingerprint read-set test now carries the water column, so it runs uprate_water_and_sewerage_charges; reading the two series by full path keeps every read inside the listed paths (dropping either path from the read set now fails the test). After the projection ends in 2032, policyengine-core grows the column by Ofwat's index in every country, as before; the docs say so. The Ofwat growth table in water-bills.md now matches the parameter. Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/water-bills.md | 4 +++- policyengine_uk/data/economic_assumptions.py | 7 +++++-- policyengine_uk/tests/test_uprating_inputs_reach_data.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index fce3e461a0..18115c7c95 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -90,6 +90,8 @@ Ofwat's bills cover England and Wales. When a dataset is projected forward, each - Scotland takes `scottish_water.water_bills`. Scottish Water sets household charges within limits from the [Water Industry Commission for Scotland](https://wics.scot/) (WICS), and every council tax band's charge is a fixed fraction of Band D, so each band rises by the same percentage. - Northern Ireland has no domestic water charges, so any charge a dataset gives its households is held flat. +This holds to the end of the dataset projection (2032). After it, policyengine-core grows every household's charge by the index the variable declares, Ofwat's England and Wales series, as it did before this split. + | Year | Scottish Water growth | Source | |------|-------------|--------| | 2022 | 4.2% | [WICS, charges scheme 2022-23](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23) | @@ -101,6 +103,6 @@ Ofwat's bills cover England and Wales. When a dataset is projected forward, each | 2028 | 3.9% | As above | | 2029 | 4.1% | As above, with CPI at 2.1% | -Each year is the charging year starting in April, so 2025 is 2025-26. Later years hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. +Each year is the charging year starting in April, so 2025 is 2025-26. Later years to 2032 hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. `policyengine_uk/utils/create_ahc_deflator.py` still reads the England and Wales index. It is a standalone script that the model does not run. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 6a665b65bf..28e992fb87 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -61,6 +61,8 @@ # household bill in England and Wales. Scottish Water's charges follow limits # set by the Water Industry Commission for Scotland. Northern Ireland has no # domestic water charges, so any a dataset gives its households are held flat. +# Past PROJECTION_END_YEAR, policyengine-core grows the column by the index +# its variable declares (Ofwat's) in every country. # An unknown region takes the England and Wales series, which covers most # households in Great Britain. WATER_BILLS_GROWTH = "gov.economic_assumptions.yoy_growth" @@ -438,13 +440,14 @@ def uprate_water_and_sewerage_charges( "Cannot uprate water and sewerage charges for unrecognised regions: " f"{sorted(unrecognised)}" ) - growth = parameters.get_child(WATER_BILLS_GROWTH) growth_by_region = {} for name, series in _WATER_GROWTH_SERIES.items(): rate = ( None if series is None - else growth.get_child(series)(current_year.time_period) + else parameters.get_child(f"{WATER_BILLS_GROWTH}.{series}")( + current_year.time_period + ) ) growth_by_region[name] = 0.0 if rate is None else rate growth_rates = region.map(growth_by_region).to_numpy() diff --git a/policyengine_uk/tests/test_uprating_inputs_reach_data.py b/policyengine_uk/tests/test_uprating_inputs_reach_data.py index 9f7143e2be..d92df6c5af 100644 --- a/policyengine_uk/tests/test_uprating_inputs_reach_data.py +++ b/policyengine_uk/tests/test_uprating_inputs_reach_data.py @@ -104,6 +104,8 @@ def mock_dataset_with_every_uprated_input() -> UKSingleYearDataset: uprating = economic_assumptions._uprating_index_map() names = set(economic_assumptions._unmapped_uprating_indices(uprating)) names |= {name for variables in uprating.values() for name in variables} + # Uprated by country rather than through the index map (#2501). + names |= economic_assumptions.COUNTRY_UPRATED_INPUTS for name in sorted(names): variable = system.variables.get(name) if variable is None or variable.value_type is not float: From 35bec9ccca582a0dace879377345b9243b6c28de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:56:53 +0100 Subject: [PATCH 117/141] Defined-benefit input and a carry-forward default for the annual allowance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey data records contributions paid this year, but the annual allowance counts a defined-benefit arrangement's increase in the value of rights (Finance Act 2004 s. 234), not the cash paid, and adds unused allowance from the three previous years (s. 228A), which surveys do not record. - defined_benefit_pension_share (input, default nil) says how much of a person's workplace contributions (employer, net pay and salary sacrifice) go to defined-benefit arrangements. defined_benefit_pension_input_amount replaces them in pension_contributions_for_annual_allowance; where it is not supplied it is those contributions times gov.simulation.pensions.defined_benefit_input_per_contribution, set at 1: in the four largest public service schemes in 2024-25 the statutory input of an active member is 0.8 to 1.2 times employer and member contributions together. With no share supplied, results are unchanged. - unused_pension_annual_allowance now defaults, from 2020-21, to an estimate: each of the five previous years' input is this year's times gov.simulation.pensions.prior_year_input_ratio (1), with input and incomes deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). A supplied value overrides it. - The 2024 charge and payers on the certified dataset are checked against HMRC's 2024-25 statistics (30,440 people, £0.3bn) within half to one and a half times, as a strict expected failure: the dataset imputes employer contributions as three times employee contributions (PolicyEngine/policyengine-uk-data#606). The taper moves into utils/pensions.py (annual_allowance_for_year) so the carry-forward estimate can apply earlier years' rules. Part of #2523. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 7 ++ changelog.d/survey-pension-input.added.md | 1 + changelog.d/survey-pension-input.changed.md | 1 + docs/engineering/pension-annual-allowance.md | 72 ++++++++++- policyengine_uk/data/uprating_indices.yaml | 3 + .../gov/simulation/pensions/README.md | 1 + ...efined_benefit_input_per_contribution.yaml | 44 +++++++ .../pensions/prior_year_input_ratio.yaml | 27 +++++ .../test_annual_allowance_against_hmrc.py | 44 +++++++ .../defined_benefit_pension_input.yaml | 113 ++++++++++++++++++ ...used_pension_annual_allowance_default.yaml | 103 ++++++++++++++++ policyengine_uk/utils/pensions.py | 45 +++++++ .../allowances/pension_annual_allowance.py | 31 ++--- .../defined_benefit_pension_contributions.py | 26 ++++ .../defined_benefit_pension_input_amount.py | 39 ++++++ ...sion_contributions_for_annual_allowance.py | 37 ++++-- .../unused_pension_annual_allowance.py | 80 +++++++++++++ .../input/defined_benefit_pension_share.py | 32 +++++ .../input/unused_pension_annual_allowance.py | 23 ---- 19 files changed, 669 insertions(+), 60 deletions(-) create mode 100644 changelog.d/survey-pension-input.added.md create mode 100644 changelog.d/survey-pension-input.changed.md create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/README.md create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml create mode 100644 policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py create mode 100644 policyengine_uk/variables/input/defined_benefit_pension_share.py delete mode 100644 policyengine_uk/variables/input/unused_pension_annual_allowance.py diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 9206674edc..f944f2e98f 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -64,3 +64,10 @@ nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_con outcome = "skipped" reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." + +[[allow]] +nodeid = "policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py::test_2024_annual_allowance_charge_and_payers_are_near_hmrc" +outcome = "xfailed" +reason = "policyengine-uk-data#606" +data = ["present"] +why = "Known data gap PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance charge several times above HMRC's. Pinned by a strict xfail until the dataset fixes it (#2523)." diff --git a/changelog.d/survey-pension-input.added.md b/changelog.d/survey-pension-input.added.md new file mode 100644 index 0000000000..70b27a10a5 --- /dev/null +++ b/changelog.d/survey-pension-input.added.md @@ -0,0 +1 @@ +- Add `defined_benefit_pension_share`, `defined_benefit_pension_contributions` and `defined_benefit_pension_input_amount`, so data can say which workplace pension contributions go to defined-benefit arrangements and supply their statutory pension input amount (Finance Act 2004 s. 234) for the annual allowance. Where the input amount is not supplied it is those contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, set at 1 from the accrual and contribution rates of the main public service schemes. diff --git a/changelog.d/survey-pension-input.changed.md b/changelog.d/survey-pension-input.changed.md new file mode 100644 index 0000000000..0c107ff5f7 --- /dev/null +++ b/changelog.d/survey-pension-input.changed.md @@ -0,0 +1 @@ +- Estimate unused annual allowance carried forward (Finance Act 2004 s. 228A) from 2020-21 where none is supplied, instead of nil: each of the five previous years' pension input is this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), with incomes and input deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 14977e2b09..8b803d500b 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -125,12 +125,11 @@ for the whole year according to age on that date; payments are not split at the birthday. Contributions before 75 that exceed the earnings relief limit still count. Adjusted income subtracts all actual member contributions under s. 228ZA(4)(d)(ii), including those excluded from the post-75 DC input. -For defined-benefit -arrangements, supply the statutory total pension input amount, including -valued DB accrual, through this variable. Cash employer funding is not a -valuation of DB rights. Survey estimates retain the existing cash proxy; -this correction does not reconstruct DB rights, overseas adjustments, unused -allowance histories or anti-avoidance arrangements under s. 228ZB. +Defined-benefit +arrangements are counted separately; see the last section. Supplying +`pension_contributions_for_annual_allowance` itself overrides the whole total. +The model does not reconstruct overseas adjustments or anti-avoidance +arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) changes NICs treatment without changing s. 228ZA. From 2029, the model includes @@ -156,3 +155,64 @@ Pension relief retains the model's aggregate earnings cap and allocation conventions. Historical years use calendar-year proxies for the named tax years; the 2015 transitional pension-input alignment rules are outside this taper calculation. + +## Survey data: defined benefits and carry forward + +Surveys record contributions paid this year. Two statutory amounts need more: +the pension input amount of a defined-benefit arrangement, and unused +allowance carried forward from earlier years. + +**Defined benefits.** A defined-benefit arrangement's pension input amount is +the increase in the value of the member's rights over the year +([s. 234](https://www.legislation.gov.uk/ukpga/2004/12/section/234)): 16 times +the annual pension, plus any separate lump sum, at the end less the same at the +start, with the start raised by CPI +([s. 235](https://www.legislation.gov.uk/ukpga/2004/12/section/235)). It is not +the cash paid in. + +- `defined_benefit_pension_share` (an input from 0 to 1, default nil) is the + share of the person's workplace contributions (employer, net pay and salary + sacrifice) paid into defined-benefit arrangements. Personal (relief at + source) contributions are money purchase. +- `defined_benefit_pension_input_amount` replaces those contributions in + `pension_contributions_for_annual_allowance`. Supply it where it is known, + as on a pension savings statement. Otherwise it is the contributions times + `gov.simulation.pensions.defined_benefit_input_per_contribution`, which is + 1. In the four largest public service schemes in 2024-25 the input of an + active member with ten years' service is 0.8 to 1.2 times employer and + member contributions together (the parameter file gives the figures), and + survey employer contributions drawn from regular contribution rates leave + out deficit repair payments. +- With the default share of nil, every contribution is counted as money + purchase input, as before. + +**Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) +adds the allowance left unused in each of the three previous tax years in +which the person was a scheme member, earliest first. `unused_pension_annual_allowance` +can be supplied. From 2020-21 it otherwise defaults to an estimate: + +- In each of the five previous years, the pension input amount was this + year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the + incomes tested for that year's taper were this year's, each deflated by + average earnings growth. +- Each year's allowance follows that year's parameters, tapered on those + incomes. Each year's excess uses the unused allowance of the three years + before it, earliest first (s. 228A(7)), and the unused allowance of the + three years before this one is carried forward. +- A person with pension input this year is taken to have been a scheme member + in each of those years; with none, nothing is carried forward. + +With a ratio of 1 the estimate matters mainly where the allowance has risen, +as it did to £60,000 in 2023-24, or where pay has grown. A ratio of 0, no input +in earlier years, gives the most there could be. + +**Against HMRC.** HMRC's Private pension statistics (July 2026) count 30,440 +individuals reporting contributions above their allowance through Self +Assessment in 2024-25 (provisional, Table 7), after carry forward, and +estimate annual allowance charges of £0.3bn (Table 6). On the certified +Enhanced FRS the model's 2024 count and charge are several times these, +because the dataset imputes employer contributions as three times employee +contributions ([policyengine-uk-data#606](https://github.com/PolicyEngine/policyengine-uk-data/issues/606)). +`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks both +against half to one and a half times HMRC's figures, as a strict expected +failure until that is fixed. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index e7f1a5e300..03ab55fdf8 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -28,6 +28,9 @@ gov.economic_assumptions.yoy_growth.finance_ni.domestic_rates: - domestic_rates gov.economic_assumptions.yoy_growth.obr.average_earnings: +# A formula of defined_benefit_pension_contributions; listed so a dataset that +# stores a valued defined-benefit input amount grows it with pay. +- defined_benefit_pension_input_amount - employee_pension_contributions - employer_pension_contributions - employment_income diff --git a/policyengine_uk/parameters/gov/simulation/pensions/README.md b/policyengine_uk/parameters/gov/simulation/pensions/README.md new file mode 100644 index 0000000000..bade491b98 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/README.md @@ -0,0 +1 @@ +# Pensions diff --git a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml new file mode 100644 index 0000000000..5d7ec93b0e --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml @@ -0,0 +1,44 @@ +description: >- + Defined-benefit pension input amount per pound of contributions paid into + defined-benefit arrangements, used for the annual allowance where the input + amount itself is not supplied. +# The statutory input amount values the increase in a member's rights over +# the year (Finance Act 2004 s. 234): 16 times the annual pension, plus any +# separate lump sum, at the end of the year less the same at the start, the +# start raised by CPI (s. 235). It is not the cash paid in. For an active +# member with ten years' service in 2024-25 (opening value raised by CPI of +# 6.7%), it is about this share of pensionable pay in the four largest public +# service schemes, against employer plus member contributions: +# - NHS 2015 (1/54 accrual, revalued at CPI + 1.5%): 34%; contributions 23.7% +# plus 5.2-12.5%, a ratio of 0.94 to 1.18. +# - Teachers 2015 (1/57, CPI + 1.6%): 33%; 28.68% plus 7.4-11.7%, 0.81 to 0.90. +# - Civil Service alpha (2.32%, CPI): 37%; 28.97% plus 4.6-8.05%, 1.00 to 1.10. +# - LGPS 2014 (1/49, CPI): 33%; about 21.1% plus 5.5-12.5%, 0.97 to 1.23. +# The ratio is lower where employer rates are higher (Police 2015, 35.3%; +# Armed Forces 2015, 73.5%). Survey employer contributions drawn from +# employers' regular contribution rates leave out deficit repair payments, so +# contributions are counted at their face value: a ratio of 1. Data that +# values defined-benefit accrual supplies defined_benefit_pension_input_amount. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Defined-benefit pension input per pound of contributions + reference: + - title: Finance Act 2004 s. 234 (defined benefits arrangements) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/234 + - title: Finance Act 2004 s. 235 (CPI increase in the opening value) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/235 + - title: NHS Pension Scheme Regulations 2015 Sch. 9 para. 13 (1/54 accrual) + href: https://www.legislation.gov.uk/uksi/2015/94/schedule/9/paragraph/13/made + - title: NHS Pension Scheme annual report and accounts 2024-25 (23.7% employer; 5.2% to 12.5% member) + href: https://assets.publishing.service.gov.uk/media/687a5e455f0f5104b9806baa/nhs-pension-scheme-annual-report-and-accounts-2024-to-2025.pdf + - title: Teachers' Pension Scheme annual report and accounts 2024-25 (1/57 accrual, CPI + 1.6%; 28.6% employer plus 0.08% levy; 7.4% to 11.7% member) + href: https://assets.publishing.service.gov.uk/media/687610ae55c4bd0544dcae36/DfE_TPS_Annual_Report_2024-25.pdf + - title: Civil Superannuation annual account 2024-25 (alpha 2.32% accrual; 28.97% employer) + href: https://www.gov.uk/government/publications/civil-superannuation-annual-account-2024-to-2025/civil-superannuation-annual-account-2024-to-2025-html + - title: LGPS Scheme Advisory Board, 2022 valuations report (21.1% average employer rate) + href: https://joomla.lgpsboard.org/index.php/2022-valuations-report + - title: Local Government Pension Scheme Regulations 2013 reg. 23 (1/49 accrual) + href: https://www.legislation.gov.uk/uksi/2013/2356/regulation/23/made diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml new file mode 100644 index 0000000000..7c42ce9f90 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -0,0 +1,27 @@ +description: >- + Pension input amount in each of the three previous tax years as a share of + this year's, after average earnings growth, used to estimate unused annual + allowance carried forward where data has no pension history. +# Finance Act 2004 s. 228A carries forward the annual allowance that the +# pension input amount left unused in each of the three previous tax years in +# which the individual was a member of a registered pension scheme, earliest +# year first. Surveys record this year's contributions only. With a ratio of +# 1, each previous year's input is this year's deflated by average earnings +# growth, as for a member saving the same share of pay each year, and the +# income tested for that year's taper is deflated the same way. A ratio of 0 +# assumes no input in those years, the most allowance there could be to +# carry forward. HMRC publishes no count of carry-forward use: Self Assessment +# reports only the excess over the allowance once it is applied. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Pension input in each of the three previous years relative to this year + reference: + - title: Finance Act 2004 s. 228A + href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A + - title: HMRC Pensions Tax Manual PTM055100 (carry forward) + href: https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + - title: HMRC, HS345 Pension savings tax charges (2025) + href: https://www.gov.uk/government/publications/pensions-tax-charges-on-any-excess-over-the-lifetime-allowance-annual-allowance-special-annual-allowance-and-on-unauthorised-payments-hs345-self/hs345-pension-savings-tax-charges-2025 diff --git a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py new file mode 100644 index 0000000000..38749baae3 --- /dev/null +++ b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py @@ -0,0 +1,44 @@ +"""The 2024-25 annual allowance charge on the certified dataset against HMRC. + +HMRC, Private pension statistics (July 2026), 2024-25 provisional: +- Table 7: 30,440 individuals reported pension contributions exceeding their + annual allowance through Self Assessment. Self Assessment reports only the + excess over the allowance after unused allowance is carried forward (HMRC, + HS345 Pension savings tax charges, 2025). +- Table 6: annual allowance charges of £0.3bn, rounded to the nearest £0.1bn. + +The model's count of payers and its charge in 2024 must each lie within half +to one and a half times these. The certified Enhanced FRS imputes employer +pension contributions as three times employee contributions +(PolicyEngine/policyengine-uk-data#606), which puts both several times above +HMRC's, so this is a strict expected failure until the dataset fixes that. The +test reports no model value: the counts rest on survey records. +""" + +import pytest + +from policyengine_uk import Microsimulation + +YEAR = 2024 +HMRC_PAYERS = 30_440 +HMRC_CHARGE = 0.3e9 +TOLERANCE = 0.5 + + +def _within(value, target): + return (1 - TOLERANCE) * target <= value <= (1 + TOLERANCE) * target + + +@pytest.mark.microsimulation +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="PolicyEngine/policyengine-uk-data#606: employer pension " + "contributions imputed as three times employee contributions", +) +def test_2024_annual_allowance_charge_and_payers_are_near_hmrc(): + charge = Microsimulation().calculate("personal_pension_contributions_tax", YEAR) + near_hmrc = bool( + _within((charge > 0).sum(), HMRC_PAYERS) and _within(charge.sum(), HMRC_CHARGE) + ) + assert near_hmrc, "The 2024 annual allowance charge is outside HMRC's band." diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml new file mode 100644 index 0000000000..ae631ab66c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml @@ -0,0 +1,113 @@ +# The total pension input amount (FA 2004 s. 229) adds each arrangement's +# input amount. A money purchase arrangement's is the contributions paid +# (s. 233); a defined-benefit arrangement's is the increase in the value of +# the member's rights (s. 234), whatever was paid in. defined_benefit_pension_share +# says how much of the workplace contributions (employer, net pay and salary +# sacrifice) go to defined-benefit arrangements; defined_benefit_pension_input_amount +# replaces them, and defaults to them times a ratio of 1. Unused allowance +# brought forward is set to nil where a case tests the charge. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/229 +# https://www.legislation.gov.uk/ukpga/2004/12/section/233 +# https://www.legislation.gov.uk/ukpga/2004/12/section/234 + +- name: Without a scheme type every workplace contribution is money purchase + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + output: + defined_benefit_pension_contributions: 0 + defined_benefit_pension_input_amount: 0 + # 20,000 + 5,000 + 2,000. + pension_contributions_for_annual_allowance: 27_000 + +- name: A defined-benefit member's contributions count at face value by default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + output: + # The employer's 20,000 and the 5,000 of net pay go to the defined-benefit + # scheme; the 2,000 of relief at source is money purchase. + defined_benefit_pension_contributions: 25_000 + defined_benefit_pension_input_amount: 25_000 + pension_contributions_for_annual_allowance: 27_000 + +- name: A supplied defined-benefit input amount replaces the contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 45_000 + output: + # 2,000 of money purchase input plus the 45,000 statement value. + pension_contributions_for_annual_allowance: 47_000 + +- name: Half the workplace contributions go to a defined-benefit arrangement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 0.5 + defined_benefit_pension_input_amount: 30_000 + output: + # Half of 25,000 goes to the defined-benefit scheme. Money purchase input + # is 2,000 + 2,500 of net pay + 10,000 from the employer, plus 30,000. + defined_benefit_pension_contributions: 12_500 + pension_contributions_for_annual_allowance: 44_500 + +- name: From age 75 the defined-benefit input still counts + period: 2026 + absolute_error_margin: 0.01 + input: + age: 76 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 30_000 + output: + # The 2,000 paid personally from 75 is unrelievable and not a money + # purchase input (s. 188(3)(a)); the net pay is in the defined-benefit + # input instead, which is not excluded. + pension_contributions_for_annual_allowance: 30_000 + +- name: The charge falls on the defined-benefit input above the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 80_000 + unused_pension_annual_allowance: 0 + output: + pension_contributions_for_annual_allowance: 80_000 + # Adjusted income 100,000 + 80,000 - 5,000 of member contributions is + # under 260,000, so the allowance is 60,000. + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 20_000 + # Reduced net income is 100,000 - 5,000 - 12,570 = 82,430, in the higher + # rate band, so the 20,000 is charged at 40%. + personal_pension_contributions_tax: 8_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml new file mode 100644 index 0000000000..89a0ef8a77 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml @@ -0,0 +1,103 @@ +# Unused annual allowance carried forward (FA 2004 s. 228A) where no history +# is supplied. From 2020-21 the default assumes the person's pension input in +# each of the five previous years was this year's (prior_year_input_ratio 1) +# deflated by average earnings growth, the incomes tested for the taper +# likewise, and that they were a scheme member in each year. Each year's +# excess uses the unused allowance of the three years before it, earliest +# first (s. 228A(7)), and the unused allowance of the three years before this +# one is carried forward. +# +# For 2026 the deflators are average earnings growth of 3.4% (2026), 5.3% +# (2025), 5.1% (2024), 6.22% (2023) and 6.14% (2022): 0.967118 for 2025, +# 0.918441 for 2024, 0.873873 for 2023, 0.822701 for 2022 and 0.775109 for +# 2021. The standard allowance is 40,000 to 2022-23 and 60,000 from 2023-24. +# Every case is a person aged 45 in London with employment income of 100,000. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/228A +# https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + +- name: Steady saving under the allowance carries forward three years of unused allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 50_000 + output: + # Inputs of 43,693.66 (2023), 45,922.03 (2024) and 48,355.90 (2025) + # leave 16,306.34 + 14,077.97 + 11,644.10 of their 60,000 allowances. + unused_pension_annual_allowance: 42_028.41 + pension_annual_allowance_chargeable_amount: 0 + +- name: An excess in a later year uses the earliest unused allowance first + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 63_000 + output: + # 2023 input 55,054.01 leaves 4,945.99 unused and 2024 input 57,861.76 + # leaves 2,138.24. 2025 input 60,928.43 exceeds 60,000 by 928.43, which + # uses 2023's unused allowance (2021 and 2022 have none). 4,017.56 + + # 2,138.24 = 6,155.80, against 7,084.23 without that ordering. + unused_pension_annual_allowance: 6_155.80 + # 63,000 - 60,000 is within the 6,155.80 carried forward. + pension_annual_allowance_chargeable_amount: 0 + +- name: Saving above the allowance every year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + output: + # Deflated inputs of 54,258 to 67,698 exceed each year's allowance. + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 10_000 + +- name: A supplied unused allowance overrides the default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + unused_pension_annual_allowance: 4_000 + output: + pension_annual_allowance_chargeable_amount: 6_000 + +- name: No pension input this year means no carry-forward is assumed + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + output: + unused_pension_annual_allowance: 0 + +- name: A tapered allowance in every earlier year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 400_000 + employer_pension_contributions: 50_000 + output: + # Adjusted income of 450,000 tapers the allowance to the 10,000 minimum + # (60,000 - (450,000 - 260,000) / 2 is below it). Deflated, the earlier + # years' incomes are tapered to their minimums too (4,000 in 2021-22 and + # 2022-23), all below the deflated inputs. + pension_annual_allowance: 10_000 + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 40_000 + +- name: Before 2020-21 the default is nil + period: 2019 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 30_000 + output: + unused_pension_annual_allowance: 0 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 41efaf46aa..0ac720fb60 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -139,3 +139,48 @@ def annual_allowance_net_income(person, period, parameters): - actual_loss_relief, ) return np.where(relievable_net_pay > 0, reconstructed, net_income) + + +def annual_allowance_for_year(adjusted_income, threshold_income, allowance, year): + """The annual allowance (FA 2004 s.228) under one tax year's parameters. + + From 2016-17 the standard allowance is tapered only when both adjusted + income and threshold income exceed their limits (s.228ZA), and the + reduction rounds down to whole pounds before the tapered minimum + applies. The original multiplier is the standard allowance less £10,000 + over £60,000, a half at the statutory £40,000; from 2020-21 the + multiplier is expressly a half. + """ + adjusted_income = np.asarray(adjusted_income, dtype=float) + if year < 2016: + return np.full(adjusted_income.shape, float(allowance.default)) + if year < 2020: + rate = (allowance.default - allowance.minimum) / allowance.taper_income_range + else: + rate = allowance.reduction_rate + # s.228ZA(3)(b) derives the threshold limit from the standard allowance. + high_income = (adjusted_income > allowance.taper) & ( + np.asarray(threshold_income) > allowance.taper - allowance.default + ) + reduction = np.floor(np.maximum(0, adjusted_income - allowance.taper) * rate) + return np.where( + high_income, + np.maximum(allowance.minimum, allowance.default - reduction), + allowance.default, + ) + + +def workplace_pension_contributions(person, period): + """Contributions to workplace arrangements. + + Employer contributions, employee contributions under a net pay + arrangement (including any supplied legacy return to ordinary pay, an + actual member payment) and the full salary sacrifice. The NICs-only + salary-sacrifice excess is not a contribution. Personal pension + contributions (relief at source) are not included. + """ + return ( + person("employee_pension_contributions", period) + + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py index aa3cd8ecf3..4c1e3f25db 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py @@ -1,19 +1,5 @@ from policyengine_uk.model_api import * - - -def _tapered_allowance(person, period, allowance, reduction_rate): - adjusted = person("pension_annual_allowance_adjusted_income", period) - threshold = person("pension_annual_allowance_threshold_income", period) - # s. 228ZA(3)(b) derives the threshold limit from the standard allowance. - high_income = (adjusted > allowance.taper) & ( - threshold > allowance.taper - allowance.default - ) - reduction = np.floor(max_(0, adjusted - allowance.taper) * reduction_rate) - return where( - high_income, - max_(allowance.minimum, allowance.default - reduction), - allowance.default, - ) +from policyengine_uk.utils.pensions import annual_allowance_for_year class pension_annual_allowance(Variable): @@ -37,12 +23,9 @@ def formula(person, period, parameters): ).gov.hmrc.income_tax.allowances.annual_allowance.default def formula_2016(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - # Original s. 228ZA(1): (A - £10,000) / £60,000, equal to one half - # at the statutory £40,000 standard allowance. - rate = (allowance.default - allowance.minimum) / allowance.taper_income_range - return _tapered_allowance(person, period, allowance, rate) - - def formula_2020(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - return _tapered_allowance(person, period, allowance, allowance.reduction_rate) + return annual_allowance_for_year( + person("pension_annual_allowance_adjusted_income", period), + person("pension_annual_allowance_threshold_income", period), + parameters(period).gov.hmrc.income_tax.allowances.annual_allowance, + period.start.year, + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py new file mode 100644 index 0000000000..73ac4b743c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import workplace_pension_contributions + + +class defined_benefit_pension_contributions(Variable): + value_type = float + entity = Person + label = "Workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The defined_benefit_pension_share of the person's workplace pension " + "contributions: employer contributions, actual employee " + "contributions under a net pay arrangement and full salary " + "sacrifice. These contributions are not a pension input amount: the " + "annual allowance counts defined_benefit_pension_input_amount for " + "them instead." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ) + + def formula(person, period, parameters): + share = clip(person("defined_benefit_pension_share", period), 0, 1) + return share * workplace_pension_contributions(person, period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py new file mode 100644 index 0000000000..25e4a9ee66 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_input_amount(Variable): + value_type = float + entity = Person + label = "Pension input amount under defined-benefit arrangements" + documentation = ( + "The increase over the pension input period in the value of the " + "individual's rights under defined-benefit arrangements (FA 2004 " + "s. 234): 16 times the annual pension, plus any separate lump sum, at " + "the end of the period less the same at its start, with the start " + "value raised by CPI (s. 235), and nil if negative. Supply it where " + "known, as on a scheme's pension savings statement. Otherwise it is " + "defined_benefit_pension_contributions times " + "gov.simulation.pensions.defined_benefit_input_per_contribution." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + dict( + title="Finance Act 2004 s. 235", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/235", + ), + dict( + title="HMRC Pensions Tax Manual PTM053301", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm053301", + ), + ] + + def formula(person, period, parameters): + ratio = parameters( + period + ).gov.simulation.pensions.defined_benefit_input_per_contribution + return ratio * person("defined_benefit_pension_contributions", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 7c4eace161..f300757607 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -6,21 +6,31 @@ class pension_contributions_for_annual_allowance(Variable): entity = Person label = "Total pension contributions counting toward the Annual Allowance" documentation = ( - "The default DC pension input amount under FA 2004 s. 233: member " + "The total pension input amount (FA 2004 s. 229). For money purchase " + "arrangements it is the input amount under s. 233: member " "contributions made before age 75, employer contributions and full " "salary sacrifice. Member contributions from age 75 are unrelievable " "under s. 188(3)(a) and excluded, while employer contributions still " "count. Contributions before 75 above the earnings relief limit " - "still count. Supply the statutory total pension input amount, " - "including valued defined-benefit accrual, to override this cash " - "proxy. The same input feeds adjusted income and the AA charge." + "still count. The defined_benefit_pension_share of workplace " + "contributions is replaced by defined_benefit_pension_input_amount, " + "the s. 234 input amount. Supply this variable to override the whole " + "total. The same input feeds adjusted income and the AA charge." ) definition_period = YEAR reference = [ + dict( + title="Finance Act 2004 s. 229", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), dict( title="Finance Act 2004 s. 233", href="https://www.legislation.gov.uk/ukpga/2004/12/section/233", ), + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), dict( title="Finance Act 2004 s. 188(3)(a)", href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", @@ -36,7 +46,16 @@ def formula(person, period, parameters): # A supplied ordinary return is an actual member payment, while the # NICs-only excess is not. Include full employer sacrifice separately # once at every age, retaining the age exclusion for member payments. - member_contributions = person("pension_contributions", period) + defined_benefit_share = clip( + person("defined_benefit_pension_share", period), 0, 1 + ) + money_purchase = 1 - defined_benefit_share + # Net pay contributions to defined-benefit arrangements are counted + # through the defined-benefit input amount instead. + net_pay = person("employee_pension_contributions", period) + member_contributions = ( + person("pension_contributions", period) - net_pay * defined_benefit_share + ) under_age_limit = ( person("age", period) < parameters( @@ -45,6 +64,10 @@ def formula(person, period, parameters): ) return ( member_contributions * under_age_limit - + person("employer_pension_contributions", period) - + person("pension_contributions_via_salary_sacrifice_from_pay", period) + + ( + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) + * money_purchase + + person("defined_benefit_pension_input_amount", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py new file mode 100644 index 0000000000..fdd811675f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -0,0 +1,80 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import annual_allowance_for_year + +# Years of assumed history: the three years whose unused allowance is carried +# forward, and the two before them, whose unused allowance an excess in those +# three years uses first. +HISTORY_YEARS = 5 + + +class unused_pension_annual_allowance(Variable): + label = "unused pension annual allowance brought forward" + documentation = ( + "Unused annual allowance from the three previous tax years that is " + "available for this tax year (FA 2004 s. 228A(5)): the amount by which " + "each of those years' annual allowance exceeded the total pension " + "input amount, for years in which the individual was a member of a " + "registered pension scheme, less any part already used up in an " + "intervening year (earliest year first). It increases this year's " + "annual allowance (s. 228A(2)). Supply it where the history is known. " + "Survey data records no past pension input, so from 2020-21 the " + "default assumes that in each of the five previous years the input " + "was this year's times gov.simulation.pensions.prior_year_input_ratio " + "and the incomes tested for the taper were this year's, each deflated " + "by average earnings growth, and that a person with pension input " + "this year was a member in each of those years. Each year's excess " + "over its allowance uses the unused allowance of the three years " + "before it, earliest first (s. 228A(7)). Before 2020-21 the default " + "is nil." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 228A", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ), + dict( + title="HMRC Pensions Tax Manual PTM055100", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100", + ), + ] + + def formula_2020(person, period, parameters): + ratio = parameters(period).gov.simulation.pensions.prior_year_input_ratio + pension_input = person("pension_contributions_for_annual_allowance", period) + adjusted_income = person("pension_annual_allowance_adjusted_income", period) + threshold_income = person("pension_annual_allowance_threshold_income", period) + # Each earlier year's amounts are this year's over the average + # earnings growth since then. + level = {0: 1.0} + for years_back in range(1, HISTORY_YEARS + 1): + growth = parameters( + period.offset(1 - years_back, YEAR) + ).gov.economic_assumptions.yoy_growth.obr.average_earnings + level[years_back] = level[years_back - 1] / (1 + growth) + unused = {} + for years_back in range(HISTORY_YEARS, 0, -1): + year = period.offset(-years_back, YEAR) + scale = level[years_back] + allowance = annual_allowance_for_year( + adjusted_income * scale, + threshold_income * scale, + parameters(year).gov.hmrc.income_tax.allowances.annual_allowance, + year.start.year, + ) + prior_input = ratio * pension_input * scale + # s. 228A(7): an excess uses the unused allowance of the three + # years before it, earliest first. + excess = max_(0, prior_input - allowance) + for earlier in (years_back + 3, years_back + 2, years_back + 1): + if earlier in unused: + used = min_(unused[earlier], excess) + unused[earlier] = unused[earlier] - used + excess = excess - used + unused[years_back] = max_(0, allowance - prior_input) + # s. 228A(4): a year counts only if the individual was a member of a + # registered pension scheme then. + return where(pension_input > 0, unused[1] + unused[2] + unused[3], 0) diff --git a/policyengine_uk/variables/input/defined_benefit_pension_share.py b/policyengine_uk/variables/input/defined_benefit_pension_share.py new file mode 100644 index 0000000000..a5bd1f4f29 --- /dev/null +++ b/policyengine_uk/variables/input/defined_benefit_pension_share.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_share(Variable): + value_type = float + entity = Person + label = "Share of workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The share, from 0 to 1, of the person's workplace pension " + "contributions that is paid into defined-benefit arrangements: " + "employer contributions, employee contributions under a net pay " + "arrangement and salary sacrifice. Personal pension contributions " + "(relief at source) are money purchase. The pension input amount of " + "a defined-benefit arrangement is the increase in the value of the " + "member's rights (FA 2004 s. 234), not the contributions paid, so the " + "annual allowance counts defined_benefit_pension_input_amount in " + "place of this share of the contributions. Data that records scheme " + "type supplies it. The default of nil treats every contribution as " + "money purchase." + ) + definition_period = YEAR + unit = "/1" + reference = [ + dict( + title="Finance Act 2004 s. 229 (total pension input amount)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), + dict( + title="Finance Act 2004 s. 234 (defined benefits arrangements)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + ] diff --git a/policyengine_uk/variables/input/unused_pension_annual_allowance.py b/policyengine_uk/variables/input/unused_pension_annual_allowance.py deleted file mode 100644 index 59b2549769..0000000000 --- a/policyengine_uk/variables/input/unused_pension_annual_allowance.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * - - -class unused_pension_annual_allowance(Variable): - label = "unused pension annual allowance brought forward" - documentation = ( - "Unused annual allowance from the three previous tax years that is " - "available for this tax year (FA 2004 s. 228A(5)): the amount by which " - "each of those years' annual allowance exceeded the total pension " - "input amount, for years in which the individual was a member of a " - "registered pension scheme, less any part already used up in an " - "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Survey data does not record past " - "pension input amounts, so this defaults to nil." - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - reference = dict( - title="Finance Act 2004 s. 228A", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", - ) From ed1902c2cc7926b7b87eaf4aef11a2db5511a0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:20:24 +0100 Subject: [PATCH 118/141] Fix carry-forward at nil in the UC deductions case; document how supplied pension inputs apply The UC earnings deductions case expects the whole excess charged, so it now fixes unused_pension_annual_allowance at nil. The prior-year ratio applies to all five years the estimate uses. A supplied unused allowance applies to its own year, and past the projection horizon the estimate replaces a stored one. A supplied defined-benefit input amount should come with the share of the contributions that fund it. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/pension-annual-allowance.md | 8 ++++++-- .../gov/simulation/pensions/prior_year_input_ratio.yaml | 8 +++++--- .../universal_credit/income/uc_earnings_deductions.yaml | 2 ++ .../hmrc/pensions/defined_benefit_pension_input_amount.py | 5 ++++- .../gov/hmrc/pensions/unused_pension_annual_allowance.py | 5 ++++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 8b803d500b..a57ef76623 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -176,7 +176,9 @@ the cash paid in. source) contributions are money purchase. - `defined_benefit_pension_input_amount` replaces those contributions in `pension_contributions_for_annual_allowance`. Supply it where it is known, - as on a pension savings statement. Otherwise it is the contributions times + as on a pension savings statement, with the share of the contributions that + fund it: contributions outside the share also count as money purchase + input. Otherwise it is the contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, which is 1. In the four largest public service schemes in 2024-25 the input of an active member with ten years' service is 0.8 to 1.2 times employer and @@ -189,7 +191,9 @@ the cash paid in. **Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) adds the allowance left unused in each of the three previous tax years in which the person was a scheme member, earliest first. `unused_pension_annual_allowance` -can be supplied. From 2020-21 it otherwise defaults to an estimate: +can be supplied for a year; the supplied value is not carried into later +years, whose window differs. From 2020-21 it otherwise defaults to an +estimate: - In each of the five previous years, the pension input amount was this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml index 7c42ce9f90..c21975e31c 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -1,7 +1,9 @@ description: >- - Pension input amount in each of the three previous tax years as a share of - this year's, after average earnings growth, used to estimate unused annual - allowance carried forward where data has no pension history. + Pension input amount in each previous tax year as a share of this year's, + after average earnings growth, used to estimate unused annual allowance + carried forward where data has no pension history. The estimate looks back + five years: the three whose unused allowance is carried forward and the two + before them, whose unused allowance an excess in those three uses first. # Finance Act 2004 s. 228A carries forward the annual allowance that the # pension input amount left unused in each of the three previous tax years in # which the individual was a member of a registered pension scheme, earliest diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 99132fcbd3..309195440b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -355,6 +355,8 @@ age: 92 employment_income: 20_000 employer_pension_contributions: 65_000 + # No allowance carried forward, so the whole excess is charged. + unused_pension_annual_allowance: 0 married_couples_allowance: 11_000 would_claim_marriage_allowance: false partner: diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py index 25e4a9ee66..01101fbff7 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py @@ -11,7 +11,10 @@ class defined_benefit_pension_input_amount(Variable): "s. 234): 16 times the annual pension, plus any separate lump sum, at " "the end of the period less the same at its start, with the start " "value raised by CPI (s. 235), and nil if negative. Supply it where " - "known, as on a scheme's pension savings statement. Otherwise it is " + "known, as on a scheme's pension savings statement, together with " + "defined_benefit_pension_share for the workplace contributions that " + "fund it: contributions outside that share are counted as money " + "purchase input as well. Otherwise it is " "defined_benefit_pension_contributions times " "gov.simulation.pensions.defined_benefit_input_per_contribution." ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py index fdd811675f..5dd5edef15 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -16,7 +16,10 @@ class unused_pension_annual_allowance(Variable): "input amount, for years in which the individual was a member of a " "registered pension scheme, less any part already used up in an " "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Supply it where the history is known. " + "annual allowance (s. 228A(2)). Supply it where the history is known; " + "a supplied value applies to its own year only, since the window of " + "years moves on, and after the dataset projection ends (2032) this " + "estimate replaces a stored value. " "Survey data records no past pension input, so from 2020-21 the " "default assumes that in each of the five previous years the input " "was this year's times gov.simulation.pensions.prior_year_input_ratio " From 70ef5a77618a7063832256002ef717c1a0b8ecff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:57:31 +0100 Subject: [PATCH 119/141] Uprate water charges by country instead of through the Ofwat list #1863 lists water_and_sewerage_charges under the Ofwat index in uprating_indices.yaml. uprate_water_and_sewerage_charges now grows it by country, so the list entry would grow it a second time: drop it, and record the column among the code-health test's deliberate exceptions. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/uprating_indices.yaml | 2 -- .../tests/code_health/test_uprating_declarations.py | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 03ab55fdf8..2c39e2c19c 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -159,7 +159,5 @@ gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: - housing_service_charges -gov.economic_assumptions.yoy_growth.ofwat.water_bills: -- water_and_sewerage_charges gov.economic_assumptions.yoy_growth.ons.population: - household_weight diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index b4b2572614..d5ba4fb1f0 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -38,6 +38,10 @@ "domestic_energy_consumption", "electricity_consumption", "gas_consumption", + # Grown by country in uprate_water_and_sewerage_charges (#2501): Ofwat's + # series for England and Wales, Scottish Water's for Scotland, none for + # Northern Ireland. A YAML entry would grow it a second time. + "water_and_sewerage_charges", } # Variables the YAML deliberately uprates by an index other than the one they From baf2d54ffb99f39166e4f71183a6380975786801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:36:56 +0100 Subject: [PATCH 120/141] Address review: keep stored carry-forward to its own year, state the assumptions, test against Table 7's excess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dataset projection no longer copies a stored unused_pension_annual_allowance into later years (YEAR_SPECIFIC_INPUTS): its three-year window moves each year, so projected years use the estimate. - The HMRC check also compares the chargeable amount with Table 7's £672m of excess, and names the gap as known data gaps, chiefly policyengine-uk-data#606: with employer contributions at a third of their stored values, count, charge and excess all fall within the band. - Parameters and docs say the defined-benefit ratio of 1 is a chosen midpoint (ten years' service; valid from 2023-24, when public service schemes revalue on 6 April; private schemes would push it below 1), that the carry-forward ratio of 1 is a convention which probably understates carry forward, and what a supplied share alone changes (only net pay to a defined-benefit scheme from age 75). The carry-forward ratio's label now matches its five-year use. - relevant_uk_earnings cites ITEPA 2003 ss. 9(2) and 11 for allowable expenses and says why pension deductions stay in; it and the carry-forward estimate note the limits left (furnished holiday lettings before 2025-26, the five-year window, 2015-16 alignment). - A carry-forward case where the taper binds only in an earlier year. - The UC benefit cap threshold notes that the national living wage parameter ends at April 2026, so the threshold holds at £10,572 after it. - Scottish water 2027-29 cite the Draft Determination's core cap (para. 9.1.3) and CPI forecast (Table 68); the HBAI validation row for water is updated, as in #2527. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 2 +- ...uc-benefit-cap-earnings-threshold.fixed.md | 2 +- docs/book/assumptions/water-bills.md | 2 +- docs/book/validation/hbai.md | 2 +- docs/engineering/pension-annual-allowance.md | 34 ++++++--- policyengine_uk/data/economic_assumptions.py | 12 ++++ .../gov/economic_assumptions/yoy_growth.yaml | 14 ++-- ...efined_benefit_input_per_contribution.yaml | 13 ++-- .../pensions/prior_year_input_ratio.yaml | 8 ++- .../test_annual_allowance_against_hmrc.py | 36 ++++++---- .../hmrc/pensions/relevant_uk_earnings.yaml | 8 ++- ...used_pension_annual_allowance_default.yaml | 19 +++++ ...test_unused_annual_allowance_projection.py | 71 +++++++++++++++++++ policyengine_uk/utils/pensions.py | 16 +++-- ...enefit_cap_earnings_exception_threshold.py | 5 +- .../unused_pension_annual_allowance.py | 15 ++-- 16 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 policyengine_uk/tests/test_unused_annual_allowance_projection.py diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index f944f2e98f..7e521a6724 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -70,4 +70,4 @@ nodeid = "policyengine_uk/tests/microsimulation/test_annual_allowance_against_hm outcome = "xfailed" reason = "policyengine-uk-data#606" data = ["present"] -why = "Known data gap PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance charge several times above HMRC's. Pinned by a strict xfail until the dataset fixes it (#2523)." +why = "Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance count, charge and excess several times above HMRC's; with a third of those contributions all three fall within the band. Pinned by a strict xfail until the data is fixed (#2523)." diff --git a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md index f453205a73..0a5a36c390 100644 --- a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md +++ b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md @@ -1 +1 @@ -- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. +- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. The national living wage parameter ends at its April 2026 rate, so the threshold stays at £10,572 in later years until that parameter is projected. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index 18115c7c95..a97a5a6df4 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -99,7 +99,7 @@ This holds to the end of the dataset projection (2032). After it, policyengine-c | 2024 | 8.8% | [WICS, charges scheme 2024-25](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25) | | 2025 | 9.9% | [Scottish Water, 28 January 2025](https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement) | | 2026 | 8.67% | [Scottish Water, 22 January 2026](https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026) | -| 2027 | 3.9% | WICS Draft Determination 2027-33: CPI + 2%, with WICS's CPI assumption of 1.9% | +| 2027 | 3.9% | [WICS Draft Determination 2027-33](https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf): the CPI + 2% core cap (para. 9.1.3) with WICS's CPI forecast of 1.9% (Table 68) | | 2028 | 3.9% | As above | | 2029 | 4.1% | As above, with CPI at 2.1% | diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index f9ccfc6609..557ea35f81 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -108,6 +108,6 @@ Each of the variables above is either derived from other variables, or is a dire | `maintenance_expenses` | gov.obr.consumer_price_index | Uprated based on CPI inflation | | `student_loan_repayments` | gov.economic_assumptions.indices.obr.average_earnings | Uprated based on OBR average earnings projections | | `rent` | gov.obr.rent | Social rents uprated by CPI+1%, private rents uprated with outturn data by region to 2025, then with backed-out private rent level growth from OBR aggregate rent forecasts (assuming social rent grows at CPI+1%) | -| `water_and_sewerage_charges` | gov.obr.consumer_price_index | Uprated based on CPI as proxy | +| `water_and_sewerage_charges` | gov.economic_assumptions.yoy_growth.ofwat.water_bills (England and Wales), gov.economic_assumptions.yoy_growth.scottish_water.water_bills (Scotland) | Uprated by each country's published bill increases, then projections; Northern Ireland has no domestic water charges and is held flat | | `mortgage_interest_repayment` | gov.obr.mortgage_interest | Uprated based on outturn Ofwat data to 2025, then Ofwat projections onwards | | `housing_service_charges` | gov.economic_assumptions.indices.obr.lagged_average_earnings | Uprated based on lagged average earnings as proxy | diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index a57ef76623..93f8ec037f 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -80,9 +80,12 @@ Ordinary pension relief, the ordinary `adjusted_total_income` and both income tests take relevant UK earnings ([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)) from one helper, `relevant_uk_earnings`: employment income chargeable to income -tax before deductions for pension contributions (pay and employer-paid taxable -statutory pay, less allowable expenses), and trading profits after capital -allowances and the trading allowance but before losses brought forward. +tax, which is net taxable earnings ([ITEPA 2003 ss. 9(2) and +11](https://www.legislation.gov.uk/ukpga/2003/1/section/11)), so pay and +employer-paid taxable statutory pay less allowable expenses; and trading +profits after capital allowances and the trading allowance but before losses +brought forward. The pension deductions that s. 327(5) also lists are the +relief these earnings limit, so they are not taken off. An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on @@ -186,14 +189,18 @@ the cash paid in. survey employer contributions drawn from regular contribution rates leave out deficit repair payments. - With the default share of nil, every contribution is counted as money - purchase input, as before. + purchase input, as before. With the ratio at 1, a supplied share alone + changes the input only for net-pay contributions to a defined-benefit + scheme from age 75, which now count, since the defined-benefit input has + no age limit. Results move only once data supplies a valued input amount. **Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) adds the allowance left unused in each of the three previous tax years in which the person was a scheme member, earliest first. `unused_pension_annual_allowance` -can be supplied for a year; the supplied value is not carried into later -years, whose window differs. From 2020-21 it otherwise defaults to an -estimate: +can be supplied for a year. A supplied or stored value applies to that year +only: the window of years moves on, so dataset projection does not copy it +into later years (`YEAR_SPECIFIC_INPUTS` in `data/economic_assumptions.py`), +which use the estimate. From 2020-21 it otherwise defaults to an estimate: - In each of the five previous years, the pension input amount was this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the @@ -208,7 +215,10 @@ estimate: With a ratio of 1 the estimate matters mainly where the allowance has risen, as it did to £60,000 in 2023-24, or where pay has grown. A ratio of 0, no input -in earlier years, gives the most there could be. +in earlier years, gives the most there could be. The ratio of 1 is a +convention, since HMRC publishes no statistics on carry forward. Real +excesses are often lumpy (a defined-benefit promotion, a one-off +contribution), so a steady history probably understates carry forward. **Against HMRC.** HMRC's Private pension statistics (July 2026) count 30,440 individuals reporting contributions above their allowance through Self @@ -217,6 +227,8 @@ estimate annual allowance charges of £0.3bn (Table 6). On the certified Enhanced FRS the model's 2024 count and charge are several times these, because the dataset imputes employer contributions as three times employee contributions ([policyengine-uk-data#606](https://github.com/PolicyEngine/policyengine-uk-data/issues/606)). -`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks both -against half to one and a half times HMRC's figures, as a strict expected -failure until that is fixed. +With employer contributions at a third of their stored values, the count, +the charge and the chargeable amount (against Table 7's £672m of excess) all +fall within half to one and a half times HMRC's figures. +`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks all three +against that band, as a strict expected failure until the data is fixed. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 28e992fb87..a59ebd9920 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -112,6 +112,11 @@ "gas_consumption", } ) +# Inputs that describe only the year they are stored for, so are not carried +# into projected years. Unused annual allowance comes from the three tax years +# before this one (Finance Act 2004 s. 228A), a window that moves each year; a +# projected year uses the variable's own estimate instead (#2523). +YEAR_SPECIFIC_INPUTS = frozenset({"unused_pension_annual_allowance"}) def _unmapped_uprating_indices(uprating): @@ -294,6 +299,13 @@ def apply_single_year_uprating( f"not {source_year}–{target_year}." ) + # Year-specific inputs are dropped from every projected year. + for table_name in current_year.table_names: + table = getattr(current_year, table_name) + stored = [name for name in YEAR_SPECIFIC_INPUTS if name in table.columns] + if stored: + table.drop(columns=stored, inplace=True) + # First, apply standard variable-YoY growth based uprating. uprating = _uprating_index_map() diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 7f6e8f775c..0c3d6284ca 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1263,10 +1263,12 @@ scottish_water: # announced and WICS approved for each year. Every council tax band's # charge is a fixed fraction of Band D, so each band rises by the same # percentage. - # - 2027-2029: the cap in WICS's Draft Determination for 2027-33 - # (consultation 30 June to 1 September 2026), CPI + 2% a year, with - # WICS's CPI assumption of 1.9%, 1.9% and 2.1%. WICS publishes the - # Final Determination on 29 October 2026; replace these with it. + # - 2027-2029: the core cap in WICS's Draft Determination for 2027-33 + # (consultation 30 June to 1 September 2026), CPI + 2% in each of the + # first three years (para. 9.1.3), with CPI from WICS's forecast of + # October CPI in the previous year, 1.9%, 1.9% and 2.1% (Table 68, from + # OBR's March 2026 forecast). WICS publishes the Final Determination on + # 29 October 2026; replace these with it. # Later years hold the 2029-30 rate. values: 2022-01-01: 0.042 @@ -1291,7 +1293,9 @@ scottish_water: href: https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement - title: Scottish Water, "Average water bills to rise £42 in 2026" (22 January 2026; 8.67%) href: https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026 - - title: WICS, "WICS publishes 2027-33 Draft Determination" (CPI + 2% cap; Final Determination 29 October 2026) + - title: WICS, Strategic Review of Charges 2027-33 Draft Determination (June 2026), para. 9.1.3 (CPI + 2% core cap) and Table 68 (CPI forecast, p. 258) + href: https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf + - title: WICS, "WICS publishes 2027-33 Draft Determination" (Final Determination 29 October 2026) href: https://wics.scot/latest/wics-publishes-2027-33-draft-determination finance_ni: domestic_rates: diff --git a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml index 5d7ec93b0e..d28a31aa1c 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml @@ -15,10 +15,15 @@ description: >- # - Civil Service alpha (2.32%, CPI): 37%; 28.97% plus 4.6-8.05%, 1.00 to 1.10. # - LGPS 2014 (1/49, CPI): 33%; about 21.1% plus 5.5-12.5%, 0.97 to 1.23. # The ratio is lower where employer rates are higher (Police 2015, 35.3%; -# Armed Forces 2015, 73.5%). Survey employer contributions drawn from -# employers' regular contribution rates leave out deficit repair payments, so -# contributions are counted at their face value: a ratio of 1. Data that -# values defined-benefit accrual supplies defined_benefit_pension_input_amount. +# Armed Forces 2015, 73.5%), and private-sector schemes, whose employer rates +# are often much higher, would push it below 1 too. Survey employer +# contributions drawn from employers' regular contribution rates leave out +# deficit repair payments. A ratio of 1, counting contributions at face value, +# is a chosen midpoint: the ten years' service and the step to exactly 1 are +# judgements. The comparison holds from 2023-24, since when these schemes +# revalue on 6 April with the same September CPI as s. 235's opening value; +# it does not hold for pension input periods before then. Data that values +# defined-benefit accrual supplies defined_benefit_pension_input_amount. values: 2004-04-06: 1 metadata: diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml index c21975e31c..c57821b4c6 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -13,13 +13,17 @@ description: >- # income tested for that year's taper is deflated the same way. A ratio of 0 # assumes no input in those years, the most allowance there could be to # carry forward. HMRC publishes no count of carry-forward use: Self Assessment -# reports only the excess over the allowance once it is applied. +# reports only the excess over the allowance once it is applied. So 1 is a +# convention, not an estimate from statistics. With smooth earnings growth it +# leaves almost nothing to carry forward for anyone over this year's +# allowance, while real excesses are often lumpy (a defined-benefit promotion, +# a one-off contribution), so it probably understates carry forward. values: 2004-04-06: 1 metadata: unit: /1 period: year - label: Pension input in each of the three previous years relative to this year + label: Pension input in each previous year relative to this year, for carry forward reference: - title: Finance Act 2004 s. 228A href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A diff --git a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py index 38749baae3..65148488ff 100644 --- a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py +++ b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py @@ -2,17 +2,21 @@ HMRC, Private pension statistics (July 2026), 2024-25 provisional: - Table 7: 30,440 individuals reported pension contributions exceeding their - annual allowance through Self Assessment. Self Assessment reports only the - excess over the allowance after unused allowance is carried forward (HMRC, - HS345 Pension savings tax charges, 2025). + annual allowance through Self Assessment, with £672m of contributions above + it. Self Assessment reports only the excess over the allowance after unused + allowance is carried forward (HMRC, HS345 Pension savings tax charges, 2025). - Table 6: annual allowance charges of £0.3bn, rounded to the nearest £0.1bn. -The model's count of payers and its charge in 2024 must each lie within half -to one and a half times these. The certified Enhanced FRS imputes employer -pension contributions as three times employee contributions -(PolicyEngine/policyengine-uk-data#606), which puts both several times above -HMRC's, so this is a strict expected failure until the dataset fixes that. The -test reports no model value: the counts rest on survey records. +The model's count of payers, its charge and its chargeable amount in 2024 must +each lie within half to one and a half times these. The excess does not depend +on marginal rates, so it is the cleaner of the two amounts. + +On the certified Enhanced FRS all three are several times HMRC's. The known +data gaps behind that are chiefly policyengine-uk-data#606: the dataset +imputes employer pension contributions as three times employee contributions. +With employer contributions at a third of their stored values all three fall +within the band, so this is a strict expected failure until the data is +fixed. The test reports no model value: the counts rest on survey records. """ import pytest @@ -21,6 +25,7 @@ YEAR = 2024 HMRC_PAYERS = 30_440 +HMRC_EXCESS = 672e6 HMRC_CHARGE = 0.3e9 TOLERANCE = 0.5 @@ -33,12 +38,17 @@ def _within(value, target): @pytest.mark.xfail( strict=True, raises=AssertionError, - reason="PolicyEngine/policyengine-uk-data#606: employer pension " - "contributions imputed as three times employee contributions", + reason="Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: " + "employer pension contributions imputed as three times employee " + "contributions", ) def test_2024_annual_allowance_charge_and_payers_are_near_hmrc(): - charge = Microsimulation().calculate("personal_pension_contributions_tax", YEAR) + sim = Microsimulation() + charge = sim.calculate("personal_pension_contributions_tax", YEAR) + excess = sim.calculate("pension_annual_allowance_chargeable_amount", YEAR) near_hmrc = bool( - _within((charge > 0).sum(), HMRC_PAYERS) and _within(charge.sum(), HMRC_CHARGE) + _within((charge > 0).sum(), HMRC_PAYERS) + and _within(charge.sum(), HMRC_CHARGE) + and _within(excess.sum(), HMRC_EXCESS) ) assert near_hmrc, "The 2024 annual allowance charge is outside HMRC's band." diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml index 434ab77203..26fdbe1c6c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml @@ -1,13 +1,17 @@ # Relevant UK earnings (FA 2004 s.189(2)) set the annual limit on relief for a # person's own pension contributions (s.190). They are employment income -# chargeable to income tax, after allowable expenses but before the pension -# deductions this relief gives, and trading income chargeable under ITTOIA +# chargeable to income tax, which is net taxable earnings (ITEPA 2003 ss.9(2) +# and 11), so allowable expenses (s.327(3)) come off; the pension deductions +# s.327(5) also lists are the relief these earnings limit, so they do not. +# They also include trading income chargeable under ITTOIA # 2005 Part 2, which is after capital allowances (CAA 2001 s.247) and the # trading allowance (ITTOIA 2005 s.783AF) but before relief for losses brought # forward (ITA 2007 s.83, a Step 2 deduction). pension_contributions_relief and # the pension deduction in adjusted_total_income (ITA 2007 s.24A(8)) use the # same figure. Each case is a person aged 45 in England with no other income. # Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/189 +# https://www.legislation.gov.uk/ukpga/2003/1/section/11 +# https://www.legislation.gov.uk/ukpga/2003/1/section/327 # https://www.legislation.gov.uk/ukpga/2001/2/section/247 # https://www.legislation.gov.uk/ukpga/2005/5/section/783AF diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml index 89a0ef8a77..bc5ff5b182 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml @@ -101,3 +101,22 @@ employer_pension_contributions: 30_000 output: unused_pension_annual_allowance: 0 + +- name: A taper that binds only in an earlier year cuts what that year leaves + period: 2022 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 180_000 + employer_pension_contributions: 20_000 + output: + # Adjusted income of 200,000 is under 2022-23's taper limit of 240,000. + # Deflated by earnings growth of 6.14% (2022), 5.9% (2021) and 0.3% + # (2020), the 2019-20 adjusted income is 177,400.16 and threshold income + # 159,660.15, over that year's limits of 150,000 and 110,000, so its + # allowance is 40,000 - floor((177,400.16 - 150,000) / 2) = 26,300, and + # its input of 17,740.02 leaves 8,559.98. 2020-21 and 2021-22 are under + # 240,000 and leave 22,206.76 and 21,156.96 of 40,000. Untapered, 2019-20 + # would leave 22,259.98, for 65,623.70 in all. + pension_annual_allowance: 40_000 + unused_pension_annual_allowance: 51_923.71 diff --git a/policyengine_uk/tests/test_unused_annual_allowance_projection.py b/policyengine_uk/tests/test_unused_annual_allowance_projection.py new file mode 100644 index 0000000000..dd8b78c249 --- /dev/null +++ b/policyengine_uk/tests/test_unused_annual_allowance_projection.py @@ -0,0 +1,71 @@ +"""A stored unused annual allowance is for its own year only (#2523). + +Unused allowance carried forward (Finance Act 2004 s. 228A) comes from the +three tax years before the one it is for, a window that moves each year. So a +dataset's stored value stays in its own year, and each projected year uses the +variable's estimate instead of a copy. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + +STORED = 50_000.0 + + +def _dataset(stored=None): + ids = np.arange(1, 2) + person = { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 45, + "employment_income": 100_000.0, + "employer_pension_contributions": 70_000.0, + } + if stored is not None: + person["unused_pension_annual_allowance"] = stored + return UKSingleYearDataset( + person=pd.DataFrame(person), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=2024, + ) + + +def test_projected_years_do_not_copy_a_stored_unused_allowance(): + extended = extend_single_year_dataset(_dataset(STORED), parameters, end_year=2027) + + assert extended[2024].person["unused_pension_annual_allowance"].tolist() == [STORED] + for year in (2025, 2026, 2027): + assert "unused_pension_annual_allowance" not in extended[year].person.columns + + +def test_a_projected_year_uses_the_estimate_not_the_stored_value(): + stored = Microsimulation(dataset=_dataset(STORED)) + estimated = Microsimulation(dataset=_dataset()) + + assert stored.calculate("unused_pension_annual_allowance", 2024).values == ( + pytest.approx([STORED]) + ) + # Saving 70,000 a year, grown with pay, exceeds each earlier allowance, so + # the estimate for 2026 carries nothing forward; a copy would give 50,000. + assert stored.calculate( + "unused_pension_annual_allowance", 2026 + ).values == pytest.approx( + estimated.calculate("unused_pension_annual_allowance", 2026).values + ) + assert stored.calculate("unused_pension_annual_allowance", 2026).values[0] == 0 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 0ac720fb60..8b5ecb3ff0 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -26,12 +26,16 @@ def trading_profits_before_losses(person, period, hmrc): def relevant_uk_earnings(person, period, hmrc): """FA 2004 s.189(2) relevant UK earnings. - Employment income chargeable to income tax, before the deductions for - pension contributions that this relief itself gives: pay and - employer-paid taxable SSP/SMP, which the model holds separately from - wages, less allowable expenses. Trading income is the profit chargeable - under ITTOIA 2005 Part 2, before losses. - A trading loss does not reduce employment earnings (HMRC PTM044100). + Employment income chargeable to income tax is net taxable earnings + (ITEPA 2003 ss.9(2) and 11): pay and employer-paid taxable SSP/SMP, which + the model holds separately from wages, less the allowable expenses in + s.327(3). s.327(5) also lists the deductions for pension contributions + (FA 2004 ss.188-194), but those are the relief that these earnings limit, + so they are not taken off; the limit would otherwise depend on itself. + Trading income is the profit chargeable under ITTOIA 2005 Part 2, before + losses. A trading loss does not reduce employment earnings (HMRC + PTM044100). Furnished holiday lettings profits, relevant UK earnings + before 2025-26 (s.189(2)(ba) and (bb)), are not modelled separately. """ return person("taxable_employment_income", period) + ( trading_profits_before_losses(person, period, hmrc) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py index e71be2a417..ec6b360596 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py @@ -13,7 +13,10 @@ class uc_benefit_cap_earnings_exception_threshold(Variable): "wage, the hourly rate in regulation 4 of the National Minimum Wage " "Regulations, multiplied by 52 and divided by 12, with any fraction " "of a pound disregarded (reg. 6(1A)(za)). Before then it was a fixed " - "£430 a month." + "£430 a month. gov.hmrc.minimum_wage.non_apprentice holds its April " + "2026 rate in later years, so the threshold stays at £10,572 a year " + "from 2026-27 until that parameter is projected, as the minimum " + "income floor does." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py index 5dd5edef15..3ed25308a6 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -16,10 +16,10 @@ class unused_pension_annual_allowance(Variable): "input amount, for years in which the individual was a member of a " "registered pension scheme, less any part already used up in an " "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Supply it where the history is known; " - "a supplied value applies to its own year only, since the window of " - "years moves on, and after the dataset projection ends (2032) this " - "estimate replaces a stored value. " + "annual allowance (s. 228A(2)). Supply it where the history is known. " + "A value supplied or stored for a year applies to that year only, " + "since the window of years moves on: dataset projection does not copy " + "it into later years, which use this estimate. " "Survey data records no past pension input, so from 2020-21 the " "default assumes that in each of the five previous years the input " "was this year's times gov.simulation.pensions.prior_year_input_ratio " @@ -27,8 +27,11 @@ class unused_pension_annual_allowance(Variable): "by average earnings growth, and that a person with pension input " "this year was a member in each of those years. Each year's excess " "over its allowance uses the unused allowance of the three years " - "before it, earliest first (s. 228A(7)). Before 2020-21 the default " - "is nil." + "before it, earliest first (s. 228A(7)). The five years leave out " + "the unused allowance an excess three years back would have used " + "first, so the estimate can carry slightly too little forward, and " + "the 2015-16 pension input period alignment rules are not applied. " + "Before 2020-21 the default is nil." ) entity = Person definition_period = YEAR From 4ed5990ca5485cb40e42737312a9837700b537a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:16:19 +0000 Subject: [PATCH 121/141] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/2431.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/2431.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7058893897..3a7fe90c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.11] - 2026-10-21 + +### Fixed + +- - Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze. + + ## [2.141.10] - 2026-10-21 ### Changed diff --git a/changelog.d/2431.fixed.md b/changelog.d/2431.fixed.md deleted file mode 100644 index 8631399fe9..0000000000 --- a/changelog.d/2431.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze. diff --git a/pyproject.toml b/pyproject.toml index ff72e06a6d..0671739a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.10" +version = "2.141.11" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From c0c010d49b77ddfb8f403c36a47c5b80afcce71d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 21 Oct 2026 11:58:00 +0100 Subject: [PATCH 122/141] ci: split the Python tests over five shards instead of four Shard 1/4 took 50 to 52 minutes on unrelated pull requests on 20 and 21 October, against a 60-minute timeout, and #1855 hit the timeout twice. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr_code_changes.yaml | 2 +- .github/workflows/test-suite.yaml | 16 +++++++++++----- Makefile | 2 +- docs/engineering/skills/testing.md | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr_code_changes.yaml b/.github/workflows/pr_code_changes.yaml index 38fddb6ee7..e959391554 100644 --- a/.github/workflows/pr_code_changes.yaml +++ b/.github/workflows/pr_code_changes.yaml @@ -162,7 +162,7 @@ jobs: run: uv pip install policyengine --system - name: Test documentation builds run: make documentation - # The YAML tests, the Python tests on four runners, and the check that + # The YAML tests, the Python tests on five runners, and the check that # every test ran once and passed, in the locked environment. As one job # they took 59 minutes on 10 October 2026 (run 38075384588), and runs were # cancelled at a 60-minute limit (issues #2099 and #2257). The release diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index e0ea3c5f84..dddd56c9d2 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -4,10 +4,10 @@ # publish. All three run the same jobs in the same locked environment. # # Jobs: the YAML policy tests (which also list every Python test), the Python -# tests split over four runners, and Result, which fails unless every listed +# tests split over five runners, and Result, which fails unless every listed # Python test ran in exactly one shard and passed, apart from the outcomes # accepted in .github/test_outcome_allowlist.toml. A caller that names its job -# Test gets the checks "Test / YAML", "Test / Python K/4" and "Test / Result"; +# Test gets the checks "Test / YAML", "Test / Python K/5" and "Test / Result"; # "Test / Result" is the one to require. name: Test suite @@ -122,17 +122,23 @@ jobs: # the 16 GB runners peaked at 15.6 GB, 283 MB short of running out # (run 38156967136; two shards of an earlier run lost their runners). # With four, each worker runs about a quarter fewer tests. + # + # Five, not four, for time: on 20 and 21 October shard 1/4 took 50 to + # 52 minutes on unrelated pull requests, close to the timeout. A pull + # request adding about a fifth more parameters (#1855) hit the timeout + # on shard 1/4 twice, and its other shards took 48 to 59 minutes (run + # 39568525017). name: Python ${{ matrix.shard }}/${{ strategy.job-total }} if: ${{ !inputs.skip }} runs-on: ubuntu-24.04-arm # The shards took 15, 20 and 15 minutes on 10 October (run - # 38085920460). 60 minutes leaves room for a slow runner and still - # stops a hung shard. + # 38085920460), and up to 52 minutes with four shards on 21 October. + # 60 minutes leaves room for a slow runner and still stops a hung shard. timeout-minutes: 60 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3, 4, 5] steps: - name: Checkout repo uses: actions/checkout@v6 diff --git a/Makefile b/Makefile index 44b7ac8e29..eee5b42189 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test-yaml: # Every Python test, serially. To spread them over four cores add # PYTEST_ARGS="-n 4 --dist worksteal" (pytest-xdist); to run what one CI -# shard runs, add "--shard K/4" (policyengine_uk/tests/conftest.py). CI runs +# shard runs, add "--shard K/5" (policyengine_uk/tests/conftest.py). CI runs # these tests through .github/workflows/test-suite.yaml, in the locked # environment (uv sync --frozen --extra dev), and also checks each test's # outcome against .github/test_outcome_allowlist.toml. diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index b506b44125..9e4df81565 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -86,10 +86,10 @@ the same suite, tree; a release tests the wheel it is about to publish, installed on its own. The suite runs the YAML policy tests in one job and splits the Python tests -over four runners. Each runner runs one shard: +over five runners. Each runner runs one shard: ```bash -pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/4 -o xfail_strict=true +pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/5 -o xfail_strict=true ``` `--shard K/N` is defined in `policyengine_uk/tests/conftest.py`. It keeps the From 7cbdc2b98f3dcfc5f5c24ef4beaab13d0f407b05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:13:11 +0000 Subject: [PATCH 123/141] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/2532.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/2532.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7fe90c55..997b0b59fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.12] - 2026-10-21 + +### Fixed + +- - Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), as the formula does, so a draw whose float32 arithmetic rounds across a penny boundary no longer fails at random. + + ## [2.141.11] - 2026-10-21 ### Fixed diff --git a/changelog.d/2532.fixed.md b/changelog.d/2532.fixed.md deleted file mode 100644 index ec250d10d2..0000000000 --- a/changelog.d/2532.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), as the formula does, so a draw whose float32 arithmetic rounds across a penny boundary no longer fails at random. diff --git a/pyproject.toml b/pyproject.toml index 0671739a8f..e3396f684c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.11" +version = "2.141.12" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 6a4192db806692814f9ea18b5ae714045d8e9cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:12:46 +0100 Subject: [PATCH 124/141] Address review: pin the BUS0415a vintage and report what the fit leaves out - Name the June 2026 release in the BUS0415a references and point them at the archived copy. The DfT address now serves the September 2026 release, which revises the last two quarters. Record the file's sha256. - Report the 2024-25 fare cap grant (GBP 515.69m) as a check the fit does not target. The table puts it at GBP 268.5m; the docs give the reason, and the script prints it. - State that the GBP 400m for 2027 is read as GBP 2 against no cap. Against a continued GBP 3 cap the table gives GBP 247.3m, which the script also prints. - Document that the CPI projection also raises fares held at the GBP 3 cap (0.4% of the outside-London yield in 2027-28, 0.8% in 2028-29). Reform costs do not move, since both runs share the reference. - Document that the national table misplaces cap savings between regions where combined authorities run their own lower fares. - Say that local_bus_uncapped_single_fare reads 0 where nothing is supplied, and point fare_index at local_bus_fares_index. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/bus-fares.md | 39 +++++++++++++++++-- .../parameters/gov/dft/bus/fare_index.yaml | 3 +- .../gov/dft/bus/local_bus_fares_index.yaml | 23 ++++++----- .../dft/bus/uncapped_single_fares/fare.yaml | 5 ++- .../dft/bus/uncapped_single_fares/share.yaml | 5 ++- .../gov/dft/local_bus_uncapped_single_fare.py | 3 +- scripts/fit_bus_cap_savings.py | 39 ++++++++++++++++++- 7 files changed, 97 insertions(+), 20 deletions(-) diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index 170db43c46..cda8b82730 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -80,15 +80,27 @@ yield(t) = benchmark_yield * index(t) / index(benchmark_year) `gov.dft.bus.local_bus_fares_index` holds DfT's [BUS0415a](https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue) quarter-end values (March 2005 = 100) for London, England outside London and -Scotland, from the release to March 2026. Each value is dated at the start of -the quarter it ends, and `fiscal_year_blend` day-weights the quarters over -6 April to 5 April, the model's year. FY2024–25 outside London is therefore +Scotland, from DfT's June 2026 release (quarters to March 2026; +[archived copy](https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods)), +the vintage Chronicle and Microcosm vendor. DfT's September 2026 release, now +served at the same address, revises the last two quarters and adds June 2026; +move to it together with Microcosm. Each value is dated at the start of the +quarter it ends, and `fiscal_year_blend` day-weights the quarters over 6 April +to 5 April, the model's year. FY2024–25 outside London is therefore (86 × 181.6 + 92 × 185.7 + 92 × 185.9 + 90 × 206.4 + 5 × 210.5) / 365 = 190.23, within 0.2% of the mean of its four quarter ends (189.9). After the last published quarter the index grows with the OBR consumer price index each January, as `private_rent_index` does. Northern Ireland has no published index, so its yield grows with CPI from the benchmark year. Wales stays unpriced. +The CPI projection also raises the fares in the index that the £3 cap holds at +£3, while the cap term below measures changes from a nominal £3. From January +2027 a ticket held at a cap therefore costs about £3 times the CPI growth since +March 2026 more than the cap allows. That overstates the outside-London yield +by 0.4% in 2027–28 and 0.8% in 2028–29, about 0.3 points more each year after. +A cap reform is measured against the same reference as the baseline, so its +cost does not move. + | Area | FY2024–25 | FY2025–26 | FY2026–27 | FY2027–28 | |---|---:|---:|---:|---:| | London | 196.10 | 196.10 | 197.13 | 201.08 | @@ -121,6 +133,11 @@ representing the end of the announced scheme. These dates follow the The cap does not affect the London series, Scottish or Northern Irish fares. England outside London uses a representative participating-service assumption; route-level participation and local lower caps require caller-supplied fares. +The table of uncapped fares below is fitted to national figures and applied +the same way in every English region outside London. Where a combined authority +already runs its own lower single fare, a national cap reform therefore +overstates the savings, and elsewhere it understates them. National totals +hold, but regional and distributional splits of a cap reform lean the wrong way. The published yield and index already include ticket discounts and the cap in force. `gov.dft.bus.reference_fares.cap` records that cap: the cap in force up @@ -184,10 +201,24 @@ lower edge, and sets the low band's fare so all singles average £2.15: | A full year at £2, 2023–24 | Fare cap support, DfT BUS05bi | £278.95m ± 10% | £280.9m | | Part of the December 2024 to March 2025 index step outside London due to the £3 cap | BUS0415a: +11.0%; neighbouring quarters 0.1% to 2.3% | 10% ± 1 point of the yield | 9.8% | | The £3 cap in 2025 | ["over £150 million"](https://www.gov.uk/government/speeches/national-bus-fare-cap) | £150m ± 20% | £140.0m | -| The £2 cap in 2027 | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £411.6m | +| The £2 cap in 2027, against no cap | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £411.6m | | Mean pre-cap price of single tickets | [DfT evaluation](https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap), Table 6 | £2.15 ± £0.10 | £2.15 | | Share of single tickets above £2 | Table 6: £0.32 / (£2.73 − £2.00) | 0.44 ± 0.05 | 0.45 | | Their mean pre-cap price | Table 6 | £2.73 ± £0.10 | £2.76 | +| 2024–25: £2 for 270 days, then £3 | Fare cap support, DfT BUS05bi | £515.69m, not fitted | £268.5m | + +The £400 million is read as £2 against no cap: the £3 scheme is funded only to +December 2026, and the £2 scheme replaces it. Read as £2 against a continued +£3 cap, the table gives £247.3m, 38% below. The script prints both. + +The 2024–25 grant, for the benchmark year itself, is reported and not fitted; +the table puts it at about half. Grant per fare-paying boarding rose 78% on +2023–24, while the outside-London index rose 6.7%, receipts per fare-paying +boarding rose 2.8%, and the cap rose only for the last quarter. The figure may +therefore hold more than that year's forgone fares (timing or coverage of +payments, not checked). The 2023–24 grant and the two announced figures all +put a full year at £2 near £0.3bn, not £0.6bn. The single £2.748 fare this +table replaced was calibrated to the 2024–25 grant. The costs use the 0.70 default single share and fare-paying boardings outside London (BUS01 journeys less BUS01c concessionary journeys: 1,281m in 2023–24 diff --git a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml index 35741aa6db..dd8d8e4786 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml @@ -1,5 +1,6 @@ description: Proportional multiplier on bus fares; a neutral reform lever, not a measured - historical fare index. + historical fare index. The published DfT fares index that moves the baseline is + gov.dft.bus.local_bus_fares_index. values: '0001-01-01': 1 metadata: diff --git a/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml index 8f403c601d..5eb5a3b58c 100644 --- a/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml @@ -1,20 +1,25 @@ description: DfT local bus fares index (table BUS0415a, March 2005 = 100) for each area - whose bus fares the model prices from journeys. Each value is the quarter-end figure DfT - publishes, dated at the start of the quarter it ends. fiscal_year_blend day-weights the - quarters over each 6 April to 5 April model year. After the last published quarter the - index grows with the OBR consumer price index. The yield per fare-paying boarding in a - model year is the benchmark yield times this index in that year over its value in the - benchmark year. Northern Ireland has no published index. + whose bus fares the model prices from journeys, from DfT's June 2026 release (quarters to + March 2026). Each value is the quarter-end figure DfT publishes, dated at the start of the + quarter it ends. fiscal_year_blend day-weights the quarters over each 6 April to 5 April + model year. After the last published quarter the index grows with the OBR consumer price + index. The yield per fare-paying boarding in a model year is the benchmark yield times this + index in that year over its value in the benchmark year. Northern Ireland has no published + index. metadata: label: local bus fares index propagate_metadata_to_children: true fiscal_year_blend: true uprating: gov.economic_assumptions.indices.obr.consumer_price_index + # The June 2026 file (sha256 04721e9ade3b39c415ce2a0bcc045ec6d4ccb8afb6e33d525f619cf452bc7adf, + # 23,434 bytes) is the vintage Chronicle and Microcosm vendor. Its DfT address now serves + # the September 2026 release, which revises the December 2025 and March 2026 quarters and + # adds June 2026; move to it together with Microcosm. The archived copy does not change. reference: - title: DfT bus statistics, table BUS0415a, local bus fares index by metropolitan area status - and country (release to March 2026) - href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods - - title: DfT bus statistics data tables, BUS04 costs, fares and revenue + and country, June 2026 release (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics data tables, BUS04 costs, fares and revenue (current release) href: https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue LONDON: 2023-01-01: 194.3 # quarter ending March 2023 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml index ae91b40374..9b87a44996 100644 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml @@ -13,8 +13,9 @@ metadata: href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: DfT bus statistics, table BUS0415a, local bus fares index (release to March 2026) - href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap in 2025), 29 October 2024 href: https://www.gov.uk/government/speeches/national-bus-fare-cap diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml index 2ba23e8153..20cdc48e8c 100644 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml @@ -12,8 +12,9 @@ metadata: href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: DfT bus statistics, table BUS0415a, local bus fares index (release to March 2026) - href: https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap in 2025), 29 October 2024 href: https://www.gov.uk/government/speeches/national-bus-fare-cap diff --git a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py index 02d8d2d0b4..0ffd93b844 100644 --- a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py +++ b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py @@ -7,7 +7,8 @@ class local_bus_uncapped_single_fare(Variable): "Optional uncapped single fare for a person's other-local-bus journeys, " "in pounds. When supplied, the person's single tickets are capped at " "this one fare; otherwise the model uses the table of uncapped single " - "fares in gov.dft.bus.uncapped_single_fares." + "fares in gov.dft.bus.uncapped_single_fares. Where nothing is supplied " + "it reads 0, which the model does not use as a fare." ) entity = Person definition_period = YEAR diff --git a/scripts/fit_bus_cap_savings.py b/scripts/fit_bus_cap_savings.py index 10780eb42f..97e7706c2d 100644 --- a/scripts/fit_bus_cap_savings.py +++ b/scripts/fit_bus_cap_savings.py @@ -31,7 +31,10 @@ "january_2025_step": (0.10, 0.01, "absolute"), # Funding of "over GBP 150 million" for the GBP 3 cap in 2025 (GBP m). "cap_3_in_2025": (150, 0.20, "relative"), - # GBP 400 million backing the GBP 2 cap in 2027 (GBP m). + # GBP 400 million backing the GBP 2 cap in 2027 (GBP m), read as GBP 2 + # against no cap: the GBP 3 scheme is funded only to December 2026 and the + # GBP 2 scheme replaces it. Read as GBP 2 against a continued GBP 3 cap, the + # fitted table gives about GBP 247m, 38% below (see UNFITTED below). "cap_2_in_2027": (400, 0.20, "relative"), # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets # averaged GBP 2.15 before the cap; those above GBP 2 averaged GBP 2.73, @@ -43,12 +46,43 @@ } +# Published figures the fit reports but does not target (GBP m). +UNFITTED = { + # BUS05bi fare cap support for 2024-25, the benchmark year: GBP 2 for 270 + # days, then GBP 3. Grant per fare-paying boarding rose 78% on 2023-24 + # while fares rose 3% to 7% and the cap rose for the last quarter, so the + # figure may hold more than that year's forgone fares (timing or coverage + # of payments, not checked). The 2023-24 grant and the two announced + # figures all put a full year at GBP 2 near GBP 0.3bn, not GBP 0.6bn. + "grant_2024_25": 515.693217, + # The GBP 400m read the other way, as GBP 2 against a continued GBP 3 cap. + "cap_2_against_3_in_2027": 400, +} + + def price_ratios(): cpi = system.parameters.gov.economic_assumptions.indices.obr.consumer_price_index base = cpi("2024-01-01") return {year: cpi(f"{year}-01-01") / base for year in (2023, 2025, 2027)} +def unfitted(shares, fares, ratios): + in_force_2024_25 = sum( + s * (f - (270 * min(f, 2) + 95 * min(f, 3)) / 365) + for s, f in zip(shares, fares) + ) + return { + "grant_2024_25": SINGLE_SHARE * FARE_PAYING_2024_25 / 1e6 * in_force_2024_25, + "cap_2_against_3_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * ( + saving(shares, fares, 2, ratios[2027]) + - saving(shares, fares, 3, ratios[2027]) + ), + } + + def saving(shares, fares, cap, ratio=1.0): """Mean saving per single ticket from a cap: sum of share * (fare - cap)+.""" return sum(s * np.maximum(ratio * f - cap, 0) for s, f in zip(shares, fares)) @@ -122,3 +156,6 @@ def fit(): values = facts(shares, fares, price_ratios()) for name, (target, tolerance, kind) in FACTS.items(): print(f"{name:18s} {values[name]:9.4f} target {target}") + checks = unfitted(shares, fares, price_ratios()) + for name, published in UNFITTED.items(): + print(f"{name:24s} {checks[name]:9.4f} published {published}, not fitted") From 07534c3a6462ceda5d75bb872237a2f47dd58040 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:10:06 +0000 Subject: [PATCH 125/141] Update package version --- CHANGELOG.md | 17 +++++++++++++++++ changelog.d/relevant-uk-earnings.fixed.md | 1 - changelog.d/scottish-water-uprating.fixed.md | 1 - changelog.d/survey-pension-input.added.md | 1 - changelog.d/survey-pension-input.changed.md | 1 - .../uc-benefit-cap-earnings-threshold.fixed.md | 1 - pyproject.toml | 2 +- 7 files changed, 18 insertions(+), 6 deletions(-) delete mode 100644 changelog.d/relevant-uk-earnings.fixed.md delete mode 100644 changelog.d/scottish-water-uprating.fixed.md delete mode 100644 changelog.d/survey-pension-input.added.md delete mode 100644 changelog.d/survey-pension-input.changed.md delete mode 100644 changelog.d/uc-benefit-cap-earnings-threshold.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 997b0b59fe..78b88add66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [2.142.0] - 2026-10-21 + +### Added + +- - Add `defined_benefit_pension_share`, `defined_benefit_pension_contributions` and `defined_benefit_pension_input_amount`, so data can say which workplace pension contributions go to defined-benefit arrangements and supply their statutory pension input amount (Finance Act 2004 s. 234) for the annual allowance. Where the input amount is not supplied it is those contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, set at 1 from the accrual and contribution rates of the main public service schemes. + +### Changed + +- - Estimate unused annual allowance carried forward (Finance Act 2004 s. 228A) from 2020-21 where none is supplied, instead of nil: each of the five previous years' pension input is this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), with incomes and input deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). + +### Fixed + +- - Use one definition of relevant UK earnings (Finance Act 2004 s. 189(2)) for pension contribution relief, the annual allowance income tests and the pension deduction in adjusted total income: employment income after allowable expenses but before pension deductions, and trading profits after capital allowances and the trading allowance but before losses brought forward. Relief previously counted pay before expenses and trading profits before capital allowances and the trading allowance. +- - Uprate water and sewerage charges by country when a dataset is projected: England and Wales by Ofwat's average bills, Scotland by a new Scottish Water series (8.8% in 2024-25, 9.9% in 2025-26 and 8.67% in 2026-27, then the CPI + 2% cap in WICS's draft 2027-33 determination), and Northern Ireland, which has no domestic water charges, not at all. The charges were previously held flat to 2030 and then grown by the England and Wales series everywhere. +- - Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. The national living wage parameter ends at its April 2026 rate, so the threshold stays at £10,572 in later years until that parameter is projected. + + ## [2.141.12] - 2026-10-21 ### Fixed diff --git a/changelog.d/relevant-uk-earnings.fixed.md b/changelog.d/relevant-uk-earnings.fixed.md deleted file mode 100644 index 7578cc84af..0000000000 --- a/changelog.d/relevant-uk-earnings.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Use one definition of relevant UK earnings (Finance Act 2004 s. 189(2)) for pension contribution relief, the annual allowance income tests and the pension deduction in adjusted total income: employment income after allowable expenses but before pension deductions, and trading profits after capital allowances and the trading allowance but before losses brought forward. Relief previously counted pay before expenses and trading profits before capital allowances and the trading allowance. diff --git a/changelog.d/scottish-water-uprating.fixed.md b/changelog.d/scottish-water-uprating.fixed.md deleted file mode 100644 index 4b15413462..0000000000 --- a/changelog.d/scottish-water-uprating.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate water and sewerage charges by country when a dataset is projected: England and Wales by Ofwat's average bills, Scotland by a new Scottish Water series (8.8% in 2024-25, 9.9% in 2025-26 and 8.67% in 2026-27, then the CPI + 2% cap in WICS's draft 2027-33 determination), and Northern Ireland, which has no domestic water charges, not at all. The charges were previously held flat to 2030 and then grown by the England and Wales series everywhere. diff --git a/changelog.d/survey-pension-input.added.md b/changelog.d/survey-pension-input.added.md deleted file mode 100644 index 70b27a10a5..0000000000 --- a/changelog.d/survey-pension-input.added.md +++ /dev/null @@ -1 +0,0 @@ -- Add `defined_benefit_pension_share`, `defined_benefit_pension_contributions` and `defined_benefit_pension_input_amount`, so data can say which workplace pension contributions go to defined-benefit arrangements and supply their statutory pension input amount (Finance Act 2004 s. 234) for the annual allowance. Where the input amount is not supplied it is those contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, set at 1 from the accrual and contribution rates of the main public service schemes. diff --git a/changelog.d/survey-pension-input.changed.md b/changelog.d/survey-pension-input.changed.md deleted file mode 100644 index 0c107ff5f7..0000000000 --- a/changelog.d/survey-pension-input.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Estimate unused annual allowance carried forward (Finance Act 2004 s. 228A) from 2020-21 where none is supplied, instead of nil: each of the five previous years' pension input is this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), with incomes and input deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). diff --git a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md deleted file mode 100644 index 0a5a36c390..0000000000 --- a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. The national living wage parameter ends at its April 2026 rate, so the threshold stays at £10,572 in later years until that parameter is projected. diff --git a/pyproject.toml b/pyproject.toml index e3396f684c..1c5d0a1601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.12" +version = "2.142.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 763d21defb3ff1e55c825006a9b16ae3c73d8cd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:17:49 +0000 Subject: [PATCH 126/141] Update package version --- CHANGELOG.md | 31 +++++++++++++++++++ .../1008-regional-childcare-plan.added.md | 1 - changelog.d/1069-pip-points-plan.added.md | 1 - changelog.d/1114.added.md | 1 - changelog.d/1209.added.md | 1 - changelog.d/1320.added.md | 1 - changelog.d/1354.added.md | 1 - changelog.d/1398.added.md | 1 - changelog.d/1621.added.md | 1 - changelog.d/1628.added.md | 1 - changelog.d/1692.added.md | 1 - changelog.d/2502.fixed.md | 1 - changelog.d/435.added.md | 1 - changelog.d/466.added.md | 1 - changelog.d/502.added.md | 1 - changelog.d/636.added.md | 1 - changelog.d/669.added.md | 1 - changelog.d/672.added.md | 1 - changelog.d/704.added.md | 1 - changelog.d/806.added.md | 1 - changelog.d/818.added.md | 1 - changelog.d/842.added.md | 1 - .../cgt-additional-band-stacking.fixed.md | 1 - pyproject.toml | 2 +- 24 files changed, 32 insertions(+), 23 deletions(-) delete mode 100644 changelog.d/1008-regional-childcare-plan.added.md delete mode 100644 changelog.d/1069-pip-points-plan.added.md delete mode 100644 changelog.d/1114.added.md delete mode 100644 changelog.d/1209.added.md delete mode 100644 changelog.d/1320.added.md delete mode 100644 changelog.d/1354.added.md delete mode 100644 changelog.d/1398.added.md delete mode 100644 changelog.d/1621.added.md delete mode 100644 changelog.d/1628.added.md delete mode 100644 changelog.d/1692.added.md delete mode 100644 changelog.d/2502.fixed.md delete mode 100644 changelog.d/435.added.md delete mode 100644 changelog.d/466.added.md delete mode 100644 changelog.d/502.added.md delete mode 100644 changelog.d/636.added.md delete mode 100644 changelog.d/669.added.md delete mode 100644 changelog.d/672.added.md delete mode 100644 changelog.d/704.added.md delete mode 100644 changelog.d/806.added.md delete mode 100644 changelog.d/818.added.md delete mode 100644 changelog.d/842.added.md delete mode 100644 changelog.d/cgt-additional-band-stacking.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b88add66..f6bb4e9a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## [2.143.0] - 2026-10-21 + +### Added + +- Added a PIP point system planning page (docs/book/programs/gov/dwp/pip-points-plan.md) setting out the SI 2013/377 scoring rules and a phased route to modelling them, starting from #1661. +- Added a regional childcare planning page (docs/book/programs/gov/dfe/regional-childcare-plan.md) listing the Scottish, Welsh and Northern Ireland childcare schemes not yet modelled and how to add them. +- Added a plan for modelling Cold Weather Payment and the Scottish Winter Heating Payment (#435) at `docs/book/programs/gov/dwp/cold-weather-payment-plan.md`. +- Added a plan for a rule-based Bereavement Support Payment and historical Widowed Parent's Allowance (#466, #465) at `docs/book/programs/gov/dwp/bereavement-support-plan.md`. +- Added a plan for modelling the Warm Home Discount (#502) at `docs/book/programs/gov/ofgem/warm-home-discount-plan.md`, reflecting the benefit-based eligibility in England and Wales from winter 2025-26 and the separate Scottish scheme. +- Added a page (docs/book/policy/future-date-announcements.md) on encoding announced policy changes as date-keyed parameter values, and how those dates become model-year values. +- Add a planning docs page for child maintenance (`docs/book/programs/gov/cms/child-maintenance.md`): how `maintenance_income` and `maintenance_expenses` are treated today, the gap for spousal maintenance in Universal Credit, and a route to modelling the Child Maintenance Service calculation. +- Add a documentation page (`docs/book/programs/gov/hmrc/pensions-comparison-stbm.md`) comparing PolicyEngine UK's treatment of pension contributions in income tax with the Scottish Tax-Benefit Model's source code, including a gap both share: contributions do not reduce adjusted net income for the personal allowance taper. +- Add a documentation page at `docs/book/programs/gov/hmrc/pension-tax-relief.md` explaining how PolicyEngine UK models personal pension tax relief (full marginal-rate reduction in taxable income via `pension_contributions_relief`, with separate annual-allowance excess charge via `personal_pension_contributions_tax`), and clarifying that the model correctly reports the *total* relief (both relief-at-source + higher-rate top-up combined) for the scenarios reported in #704. +- Added a plan for a reform impacts dashboard (#806) at `docs/book/validation/reform-impacts-dashboard-plan.md`, tracking the regression fixtures in `reforms_config.yaml` over model and dataset versions. +- Documented how capital gains are modelled and imputed (#818, #817) at `docs/book/assumptions/capital-gains-distribution-plan.md`, including the Microcosm UK stages that now fit gains jointly to HMRC Table 3 and what is left to do. +- Add a planning docs page (`docs/book/assumptions/non-uk-benefit-receipt-plan.md`) on benefit spending that does not reach UK private households (payments abroad, institutions, fraud and error) and how calibration targets should exclude it. +- Add a planning docs page (`docs/book/policy/indirect-tax-effects-plan.md`) on letting indirect tax revenue respond to direct tax reforms through a consumption elasticity, from a single household-level factor to per-category elasticities and a link to `consumer_incident_tax_revenue_change`. +- Documented the Ubuntu vs macOS reproducibility issue (#1209) at `docs/book/validation/ubuntu-vs-macos.md`: the import-time uprating cause, how it was resolved, and the October 2026 check that the parameter tree is byte-identical across macOS and Linux. +- Add a planning docs page for firm-level VAT (`docs/book/programs/gov/hmrc/firm-vat.md`): a proposed `Firm` entity, the registration threshold and supply split, output, input and net VAT, the data needed for a synthetic firm panel, and open questions. +- Added an effective take-up analysis plan (docs/book/assumptions/takeup-analysis-plan.md) listing each programme's take-up flag, reference parameter and current handling, where the data builds' seed rates live, and how to measure drift from them. +- Added a child poverty validation page (docs/book/validation/child-poverty-tcl.md) comparing the two-child limit removal on Microcosm UK 2024-25 (about 463,000 children lifted after housing costs, £3.26bn in 2029-30) with the DWP, Budget 2025 (OBR-certified) and Resolution Foundation estimates. +- Added a UK pipeline alignment planning page (docs/book/assumptions/uk-pipeline-alignment-plan.md): the model-side take-up gap still open, and what Microcosm UK now covers (reported-anchored take-up, the SPI stage-2 fill). +- Documented the policyengine.py household-impact zero-return diagnosis (docs/book/validation/policyengine-py-household-impact.md); the UK model returns the expected values, re-checked on 2.123.7. +- Add a planning docs page (`docs/book/assumptions/mp30k-uk-switch-plan.md`) setting out the gates and rollback path for changing the default dataset. It was written for Microplex UK and now applies to its successor, Microcosm UK. + +### Fixed + +- Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band, and set the annual exempt amount against the gains where each pound saves most tax. +- - Northern Ireland Local Housing Allowance rates now follow new gov.dfc.LHA.freeze and gov.dfc.LHA.percentile parameters, so reforms to gov.dwp.LHA.freeze and gov.dwp.LHA.percentile change Great Britain rates only. + + ## [2.142.0] - 2026-10-21 ### Added diff --git a/changelog.d/1008-regional-childcare-plan.added.md b/changelog.d/1008-regional-childcare-plan.added.md deleted file mode 100644 index 339f8f401f..0000000000 --- a/changelog.d/1008-regional-childcare-plan.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a regional childcare planning page (docs/book/programs/gov/dfe/regional-childcare-plan.md) listing the Scottish, Welsh and Northern Ireland childcare schemes not yet modelled and how to add them. diff --git a/changelog.d/1069-pip-points-plan.added.md b/changelog.d/1069-pip-points-plan.added.md deleted file mode 100644 index 759d59fe3d..0000000000 --- a/changelog.d/1069-pip-points-plan.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a PIP point system planning page (docs/book/programs/gov/dwp/pip-points-plan.md) setting out the SI 2013/377 scoring rules and a phased route to modelling them, starting from #1661. diff --git a/changelog.d/1114.added.md b/changelog.d/1114.added.md deleted file mode 100644 index 1d2a96c92b..0000000000 --- a/changelog.d/1114.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a planning docs page (`docs/book/policy/indirect-tax-effects-plan.md`) on letting indirect tax revenue respond to direct tax reforms through a consumption elasticity, from a single household-level factor to per-category elasticities and a link to `consumer_incident_tax_revenue_change`. diff --git a/changelog.d/1209.added.md b/changelog.d/1209.added.md deleted file mode 100644 index cf2da497f8..0000000000 --- a/changelog.d/1209.added.md +++ /dev/null @@ -1 +0,0 @@ -Documented the Ubuntu vs macOS reproducibility issue (#1209) at `docs/book/validation/ubuntu-vs-macos.md`: the import-time uprating cause, how it was resolved, and the October 2026 check that the parameter tree is byte-identical across macOS and Linux. diff --git a/changelog.d/1320.added.md b/changelog.d/1320.added.md deleted file mode 100644 index 0c7cedbd66..0000000000 --- a/changelog.d/1320.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a planning docs page for firm-level VAT (`docs/book/programs/gov/hmrc/firm-vat.md`): a proposed `Firm` entity, the registration threshold and supply split, output, input and net VAT, the data needed for a synthetic firm panel, and open questions. diff --git a/changelog.d/1354.added.md b/changelog.d/1354.added.md deleted file mode 100644 index e21dfc8a1e..0000000000 --- a/changelog.d/1354.added.md +++ /dev/null @@ -1 +0,0 @@ -Added an effective take-up analysis plan (docs/book/assumptions/takeup-analysis-plan.md) listing each programme's take-up flag, reference parameter and current handling, where the data builds' seed rates live, and how to measure drift from them. diff --git a/changelog.d/1398.added.md b/changelog.d/1398.added.md deleted file mode 100644 index 3119715c17..0000000000 --- a/changelog.d/1398.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a child poverty validation page (docs/book/validation/child-poverty-tcl.md) comparing the two-child limit removal on Microcosm UK 2024-25 (about 463,000 children lifted after housing costs, £3.26bn in 2029-30) with the DWP, Budget 2025 (OBR-certified) and Resolution Foundation estimates. diff --git a/changelog.d/1621.added.md b/changelog.d/1621.added.md deleted file mode 100644 index 6c13eaceb8..0000000000 --- a/changelog.d/1621.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a UK pipeline alignment planning page (docs/book/assumptions/uk-pipeline-alignment-plan.md): the model-side take-up gap still open, and what Microcosm UK now covers (reported-anchored take-up, the SPI stage-2 fill). diff --git a/changelog.d/1628.added.md b/changelog.d/1628.added.md deleted file mode 100644 index 9bf39b9e40..0000000000 --- a/changelog.d/1628.added.md +++ /dev/null @@ -1 +0,0 @@ -Documented the policyengine.py household-impact zero-return diagnosis (docs/book/validation/policyengine-py-household-impact.md); the UK model returns the expected values, re-checked on 2.123.7. diff --git a/changelog.d/1692.added.md b/changelog.d/1692.added.md deleted file mode 100644 index 1816c55e99..0000000000 --- a/changelog.d/1692.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a planning docs page (`docs/book/assumptions/mp30k-uk-switch-plan.md`) setting out the gates and rollback path for changing the default dataset. It was written for Microplex UK and now applies to its successor, Microcosm UK. diff --git a/changelog.d/2502.fixed.md b/changelog.d/2502.fixed.md deleted file mode 100644 index 90854a8756..0000000000 --- a/changelog.d/2502.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Northern Ireland Local Housing Allowance rates now follow new gov.dfc.LHA.freeze and gov.dfc.LHA.percentile parameters, so reforms to gov.dwp.LHA.freeze and gov.dwp.LHA.percentile change Great Britain rates only. diff --git a/changelog.d/435.added.md b/changelog.d/435.added.md deleted file mode 100644 index 4ac12a8377..0000000000 --- a/changelog.d/435.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a plan for modelling Cold Weather Payment and the Scottish Winter Heating Payment (#435) at `docs/book/programs/gov/dwp/cold-weather-payment-plan.md`. diff --git a/changelog.d/466.added.md b/changelog.d/466.added.md deleted file mode 100644 index 5bf393a6b5..0000000000 --- a/changelog.d/466.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a plan for a rule-based Bereavement Support Payment and historical Widowed Parent's Allowance (#466, #465) at `docs/book/programs/gov/dwp/bereavement-support-plan.md`. diff --git a/changelog.d/502.added.md b/changelog.d/502.added.md deleted file mode 100644 index 6f0751e936..0000000000 --- a/changelog.d/502.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a plan for modelling the Warm Home Discount (#502) at `docs/book/programs/gov/ofgem/warm-home-discount-plan.md`, reflecting the benefit-based eligibility in England and Wales from winter 2025-26 and the separate Scottish scheme. diff --git a/changelog.d/636.added.md b/changelog.d/636.added.md deleted file mode 100644 index aefcb63f8e..0000000000 --- a/changelog.d/636.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a page (docs/book/policy/future-date-announcements.md) on encoding announced policy changes as date-keyed parameter values, and how those dates become model-year values. diff --git a/changelog.d/669.added.md b/changelog.d/669.added.md deleted file mode 100644 index be24e5dbce..0000000000 --- a/changelog.d/669.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a planning docs page for child maintenance (`docs/book/programs/gov/cms/child-maintenance.md`): how `maintenance_income` and `maintenance_expenses` are treated today, the gap for spousal maintenance in Universal Credit, and a route to modelling the Child Maintenance Service calculation. diff --git a/changelog.d/672.added.md b/changelog.d/672.added.md deleted file mode 100644 index 2e0a22b781..0000000000 --- a/changelog.d/672.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a documentation page (`docs/book/programs/gov/hmrc/pensions-comparison-stbm.md`) comparing PolicyEngine UK's treatment of pension contributions in income tax with the Scottish Tax-Benefit Model's source code, including a gap both share: contributions do not reduce adjusted net income for the personal allowance taper. diff --git a/changelog.d/704.added.md b/changelog.d/704.added.md deleted file mode 100644 index 6cd2db94ea..0000000000 --- a/changelog.d/704.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a documentation page at `docs/book/programs/gov/hmrc/pension-tax-relief.md` explaining how PolicyEngine UK models personal pension tax relief (full marginal-rate reduction in taxable income via `pension_contributions_relief`, with separate annual-allowance excess charge via `personal_pension_contributions_tax`), and clarifying that the model correctly reports the *total* relief (both relief-at-source + higher-rate top-up combined) for the scenarios reported in #704. diff --git a/changelog.d/806.added.md b/changelog.d/806.added.md deleted file mode 100644 index 3a5e0d597d..0000000000 --- a/changelog.d/806.added.md +++ /dev/null @@ -1 +0,0 @@ -Added a plan for a reform impacts dashboard (#806) at `docs/book/validation/reform-impacts-dashboard-plan.md`, tracking the regression fixtures in `reforms_config.yaml` over model and dataset versions. diff --git a/changelog.d/818.added.md b/changelog.d/818.added.md deleted file mode 100644 index ee8c0776c2..0000000000 --- a/changelog.d/818.added.md +++ /dev/null @@ -1 +0,0 @@ -Documented how capital gains are modelled and imputed (#818, #817) at `docs/book/assumptions/capital-gains-distribution-plan.md`, including the Microcosm UK stages that now fit gains jointly to HMRC Table 3 and what is left to do. diff --git a/changelog.d/842.added.md b/changelog.d/842.added.md deleted file mode 100644 index a6930079a4..0000000000 --- a/changelog.d/842.added.md +++ /dev/null @@ -1 +0,0 @@ -Add a planning docs page (`docs/book/assumptions/non-uk-benefit-receipt-plan.md`) on benefit spending that does not reach UK private households (payments abroad, institutions, fraud and error) and how calibration targets should exclude it. diff --git a/changelog.d/cgt-additional-band-stacking.fixed.md b/changelog.d/cgt-additional-band-stacking.fixed.md deleted file mode 100644 index c14c40830f..0000000000 --- a/changelog.d/cgt-additional-band-stacking.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band, and set the annual exempt amount against the gains where each pound saves most tax. diff --git a/pyproject.toml b/pyproject.toml index 1c5d0a1601..9201a85d61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.142.0" +version = "2.143.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 72d4aa7fd8e7f694e3808a48bb1c30513cd3d3da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:44:24 +0000 Subject: [PATCH 127/141] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/1069.added.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/1069.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f6bb4e9a85..8ab2f96796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.144.0] - 2026-10-21 + +### Added + +- Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from each person's latest points when the category is not provided as input. A person's category supplied for an earlier year still carries forward unless that person has points for a later year. + + ## [2.143.0] - 2026-10-21 ### Added diff --git a/changelog.d/1069.added.md b/changelog.d/1069.added.md deleted file mode 100644 index 5f3e6d5bfb..0000000000 --- a/changelog.d/1069.added.md +++ /dev/null @@ -1 +0,0 @@ -Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from each person's latest points when the category is not provided as input. A person's category supplied for an earlier year still carries forward unless that person has points for a later year. diff --git a/pyproject.toml b/pyproject.toml index 9201a85d61..f1522980e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.143.0" +version = "2.144.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From ac43212a0c0e478e687777235640d1ea571a836c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:25:13 +0100 Subject: [PATCH 128/141] Scope legacy-benefit closure to each award owner --- changelog.d/2395.fixed.md | 1 + .../tests/legacy_award_work_reference.py | 62 +++--- .../esa_income_remunerative_work.yaml | 45 ++++- .../jsa_income_remunerative_work.yaml | 51 ++++- .../person_legacy_benefits_closed.yaml | 147 ++++++++++++++ .../test_legacy_award_readers_properties.py | 5 +- .../test_legacy_award_work_properties.py | 26 ++- .../tests/test_legacy_closure_scope.py | 190 ++++++++++++++++++ policyengine_uk/utils/benefit_unit.py | 9 +- .../variables/gov/dwp/_legacy_award_payee.py | 35 +--- .../variables/gov/dwp/esa_income.py | 6 +- .../variables/gov/dwp/esa_income_eligible.py | 18 +- .../gov/dwp/is_on_income_based_jsa.py | 7 +- .../gov/dwp/is_on_income_related_esa.py | 7 +- .../variables/gov/dwp/jsa_income.py | 6 +- .../variables/gov/dwp/jsa_income_eligible.py | 17 +- .../legacy_benefits_closed.py | 88 ++++---- .../person_legacy_benefits_closed.py | 37 ++++ 18 files changed, 599 insertions(+), 158 deletions(-) create mode 100644 changelog.d/2395.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml create mode 100644 policyengine_uk/tests/test_legacy_closure_scope.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py diff --git a/changelog.d/2395.fixed.md b/changelog.d/2395.fixed.md new file mode 100644 index 0000000000..7d3dce9d6d --- /dev/null +++ b/changelog.d/2395.fixed.md @@ -0,0 +1 @@ +Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 8ff44c9cf0..367f3102ee 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -33,9 +33,9 @@ (2)(g)), so the limit is 24 either way. - The claimant is a member who reports the award; when the claimant or partner reports one, only they are candidates. -- Both screens also end the award once the family's legacy benefits have - closed (legacy_benefits_closed, from PolicyEngine/policyengine-uk#2119), - read here as the model applies it; see legacy_benefits_closed below. +- Reports whose own award is closed are excluded before selecting claimants. + The existing annual proxy is read separately for the couple and for each + other adult; it is not a notice or deadline simulation. """ import math @@ -143,19 +143,13 @@ def esa_claimant_in_remunerative_work(adult, parameters): def legacy_benefits_closed(couple, others, parameters): - """legacy_benefits_closed for a benefit unit whose members (couple + - others, every member who may report) report no tax credits. - - Once Income Support or income-based JSA has closed (their active - parameters), a report of it closes the family's legacy benefits, unless - someone reports Pension Credit or the claimant and partner have all - reached the qualifying age for it (SI 2014/1230 reg 8(2B)). This is the - model's reading, not the law's: the model reads every member's reports - and ends every member's award, where the migration notice goes to the - claimant and partner (reg 44(1), (4)) and ends their awards (reg 46(1)). - When the result depends on the claimant's and partner's ages, their ages - must keep clear of 65 to 69, where the qualifying age moves; an assertion - checks this.""" + """Annual closure proxy for the claimant's and partner's own award. + + Reports by other members neither close nor shield it (SI 2014/1230 + regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a + notice/deadline model. No tax-credit reports are generated here. Ages + avoid the changing Pension Credit qualifying-age boundary. + """ dwp = parameters.gov.dwp members = couple + others assert not any( @@ -165,7 +159,7 @@ def legacy_benefits_closed(couple, others, parameters): ), "tax credit closures are not read here" def reports(variable): - return any(member.get(variable, 0) > 0 for member in members) + return any(member.get(variable, 0) > 0 for member in couple) closed_report = ( reports("income_support_reported") and not dwp.income_support.active @@ -176,14 +170,24 @@ def reports(variable): return not all(adult["age"] >= 70 for adult in couple) -def candidates(couple, others, reported): +def candidates(couple, others, reported, parameters): """Indexes (into couple + others) of members who could be the claimant.""" members = couple + others - couple_reports = any(adult.get(reported, 0) > 0 for adult in couple) + couple_closed = legacy_benefits_closed(couple, [], parameters) + open_reports = [ + member.get(reported, 0) > 0 + and not ( + couple_closed + if i < len(couple) + else legacy_benefits_closed([member], [], parameters) + ) + for i, member in enumerate(members) + ] + couple_reports = any(open_reports[: len(couple)]) return [ i - for i, member in enumerate(members) - if member.get(reported, 0) > 0 and (i < len(couple) or not couple_reports) + for i, reports in enumerate(open_reports) + if reports and (i < len(couple) or not couple_reports) ] @@ -206,13 +210,9 @@ def other_member_works(i): passes = any( not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) - for i in candidates(couple, others, "esa_income_reported") - ) - return ( - passes - and capital <= ESA.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "esa_income_reported", parameters) ) + return passes and capital <= ESA.capital.limit def jsa_screen(couple, others, capital, parameters): @@ -234,10 +234,6 @@ def other_member_works(i): passes = any( weekly_hours(members[i]) < WORK.claimant_hours and not other_member_works(i) - for i in candidates(couple, others, "jsa_income_reported") - ) - return ( - passes - and capital <= JSA.income.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "jsa_income_reported", parameters) ) + return passes and capital <= JSA.income.capital.limit diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index 3fbc09d789..c57c401bbf 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -1179,7 +1179,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_related_esa: [false, true] -- name: A non-dependent adult's own ESA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own ESA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -1205,15 +1205,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. esa_income_claimant_remunerative_work: [false, false, false] - esa_income_eligible: false - esa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_related_esa: [false, false, false] + esa_income_eligible: true + esa_income: 5000 + claimant_or_partner_esa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_related_esa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(h)). @@ -1248,3 +1246,32 @@ # Without the work test the partner's award would count as income-related # ESA of the other member of the couple and bar the claim. income_support_eligible: true + +- name: Issue 2395 outside closed IS report leaves the couple ESA award open + period: 2027 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + esa_income_reported: 5_000 + partner: + age: 30 + is_claimant_or_partner: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 1_000 + benunits: + unit: + members: [claimant, partner, outside] + esa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside] + output: + legacy_benefits_closed: false + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml index ce4b1ee145..daeb1b21c8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml @@ -445,7 +445,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_based_jsa: [false, true] -- name: A non-dependent adult's own JSA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own JSA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -471,15 +471,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. jsa_remunerative_work_hours: [0, 0, 0] - jsa_income_eligible: false - jsa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_based_jsa: [false, false, false] + jsa_income_eligible: true + jsa_income: 3000 + claimant_or_partner_jsa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_based_jsa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(f)). @@ -508,3 +506,38 @@ jsa_income_eligible: false jsa_income: 0 income_support_eligible: true + +- name: Issue 2395 couple closure leaves a separate outside JSA award open + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + is_parent: true + child_tax_credit_reported: 2_000 + partner: + age: 30 + is_claimant_or_partner: true + is_parent: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + child: + age: 5 + is_claimant_or_partner: false + benunits: + unit: + members: [claimant, partner, outside, child] + jsa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside, child] + output: + legacy_benefits_closed: true + jsa_income: 5_000 + claimant_or_partner_jsa_income: 0 + is_on_income_based_jsa: [false, false, true, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml new file mode 100644 index 0000000000..2f4f43bbfb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml @@ -0,0 +1,147 @@ +[ + { + "name": "Case 1, outside closed report leaves the couple open.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "esa_income_reported": 5000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + true + ], + "legacy_benefits_closed": false + } + }, + { + "name": "Case 2, couple closure leaves the outside award open.", + "period": 2025, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "child_tax_credit_reported": 2000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "esa_income_reported": 5000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + true, + true, + false + ], + "legacy_benefits_closed": true + } + }, + { + "name": "Case 3, outside Pension Credit age exception uses their own input.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000, + "has_attained_state_pension_credit_qualifying_age": true, + "is_SP_age": false + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + false + ], + "legacy_benefits_closed": false + } + } +] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index ef44e02ecc..7850e3a79a 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -61,9 +61,8 @@ (esa_income_eligible and jsa_income_eligible, read independently from the law in legacy_award_work_reference). The screens also end the award once the family's legacy benefits have closed (legacy_benefits_closed, #2119), - and the model then ends every member's status too; no family here reaches - that in 2025, when Income Support and income-based JSA are open and - nobody reports tax credits; + for their own award only; no family here reaches that in 2025, when + Income Support and income-based JSA are open and nobody reports tax credits; - a person is on an award exactly when they are the payee of their couple's positive award (the claimant or partner who reports it, or the claimant) or, for anyone else, when their own claim passes: they are not engaged in diff --git a/policyengine_uk/tests/test_legacy_award_work_properties.py b/policyengine_uk/tests/test_legacy_award_work_properties.py index 2e2ecc3f14..1779a07305 100644 --- a/policyengine_uk/tests/test_legacy_award_work_properties.py +++ b/policyengine_uk/tests/test_legacy_award_work_properties.py @@ -23,10 +23,9 @@ JSA regulations have no exception for carers doing unrelated paid work); - when the claimant or partner reports the award, adding a member outside the family (a non-dependent adult, with any award and any work) never - changes either screen's work and capital tests. It can change a screen - only through the closure, which the model reads from every member's - reports: the screen with them is the screen without them, while their - report has not closed the family's legacy benefits. + changes an open couple claim's screen or award. If the couple's own + reports have closed, an outside member's separate open claim may pass + the aggregate screen without reopening the couple's award. Adults are employees (pay within the basic rate band), self-employed (a profit within the Class 4 upper limit) or employees with a self-employment @@ -318,11 +317,20 @@ def test_member_outside_the_family_never_changes_the_couples_screen(units, year, for i, (couple, *_rest) in enumerate(without): assert not flags[ends[n + i] - 1] if any(a[reported] > 0 for a in couple): - # The closure reads every member's reports (#2119). - closes = legacy_benefits_closed(couple, [extras[i]], parameters) - if closes and not legacy_benefits_closed(couple, [], parameters): - event("an outside member's report closes the family's") - assert eligible[n + i] == (eligible[i] and not closes), ( + closed = legacy_benefits_closed(couple, [], parameters) + # An open couple report still selects the same work screen. + # Once closed, only an outside member's separate claim can + # be a candidate, so the aggregate screen may then change. + if not closed: + assert eligible[n + i] == eligible[i], ( + variable, + units[i], + extras[i], + ) + couple_award = sim.calculate( + "claimant_or_partner_" + reported.removesuffix("_reported"), year + ) + assert couple_award[n + i] == couple_award[i], ( variable, units[i], extras[i], diff --git a/policyengine_uk/tests/test_legacy_closure_scope.py b/policyengine_uk/tests/test_legacy_closure_scope.py new file mode 100644 index 0000000000..4126a21ddc --- /dev/null +++ b/policyengine_uk/tests/test_legacy_closure_scope.py @@ -0,0 +1,190 @@ +"""Ownership of the model's annual legacy-closure proxy (#2395). + +These fixtures keep the existing annual dates and eligibility screens. They +do not represent actual migration notices, deadlines, or statutory run-ons. +The separate adult's award is conditional on their own zero-capital, +non-working claim; sharing a benefit unit does not make it the couple's. +""" + +import pytest + +from policyengine_uk import Simulation + + +def household(year, claimant=None, partner=None, outside=None, child=False): + members = { + "claimant": {"age": 30, "is_claimant_or_partner": True}, + "partner": {"age": 30, "is_claimant_or_partner": True}, + "outside": { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + }, + } + for name, changes in ( + ("claimant", claimant), + ("partner", partner), + ("outside", outside), + ): + members[name].update(changes or {}) + if child: + members["claimant"]["is_parent"] = True + members["partner"]["is_parent"] = True + members["child"] = {"age": 5, "is_claimant_or_partner": False} + return { + "people": { + name: {key: {str(year): value} for key, value in inputs.items()} + for name, inputs in members.items() + }, + "benunits": { + "unit": { + "members": list(members), + "esa_income_assessable_capital": {str(year): 0}, + "jsa_income_assessable_capital": {str(year): 0}, + } + }, + "households": {"household": {"members": list(members)}}, + } + + +@pytest.mark.parametrize("report", ["income_support_reported", "jsa_income_reported"]) +@pytest.mark.parametrize("payee", ["claimant", "partner"]) +def test_outside_closed_report_does_not_close_couple_award(report, payee): + inputs = {payee: {"esa_income_reported": 5_000}} + sim = Simulation(situation=household(2027, **inputs, outside={report: 1_000})) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 5_000 + assert sim.calculate("esa_income", 2027)[0] == 5_000 + + +def test_outside_pension_credit_report_does_not_shield_couple_closure(): + sim = Simulation( + situation=household( + 2027, + claimant={"income_support_reported": 1_000, "esa_income_reported": 5_000}, + outside={"age": 70, "pension_credit_reported": 1_000}, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 0 + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +def test_couple_closure_does_not_end_outside_own_award(award): + # Tax credits are closed in 2025, while income-based JSA is still active. + sim = Simulation( + situation=household( + 2025, + claimant={"child_tax_credit_reported": 2_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2025)[0] + assert sim.calculate(award, 2025)[0] == 5_000 + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == 0 + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert sim.calculate(status, 2025).tolist() == [False, False, True, False] + + +def test_outside_own_closed_report_still_ends_only_their_own_claim(): + sim = Simulation( + situation=household( + 2027, + claimant={"esa_income_reported": 5_000}, + outside={"esa_income_reported": 5_000, "income_support_reported": 1_000}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("esa_income", 2027)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2027).tolist() == [ + True, + False, + False, + ] + + +def test_preclosure_report_does_not_end_outside_own_award(): + sim = Simulation( + situation=household( + 2024, + claimant={"child_tax_credit_reported": 2_000}, + outside={"esa_income_reported": 5_000}, + child=True, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2024)[0] + assert sim.calculate("esa_income", 2024)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2024).tolist() == [ + False, + False, + True, + False, + ] + + +def test_existing_pension_age_exception_is_preserved(): + sim = Simulation( + situation=household( + 2027, + claimant={"age": 70, "income_support_reported": 1_000}, + partner={"age": 70}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +@pytest.mark.parametrize( + "mode", ["formula", "raw_total", "distinct_entered", "nil", "neutralized"] +) +def test_closed_reports_preserve_stored_award_value_contract(award, mode): + inputs = household( + 2025, + claimant={"child_tax_credit_reported": 2_000, f"{award}_reported": 5_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + if mode in {"raw_total", "distinct_entered", "nil"}: + entered = {"raw_total": 10_000, "distinct_entered": 7_000, "nil": 0}[mode] + inputs["benunits"]["unit"][award] = {"2025": entered} + sim = Simulation(situation=inputs) + if mode == "neutralized": + sim.tax_benefit_system.neutralize_variable(award) + # Raw report totals retain the held-award path; distinct direct amounts + # remain the couple's. Formula awards instead omit only closed reports. + aggregate, scoped = { + "formula": (5_000, 0), + "raw_total": (10_000, 5_000), + "distinct_entered": (7_000, 7_000), + "nil": (0, 0), + "neutralized": (0, 0), + }[mode] + assert sim.calculate(award, 2025)[0] == aggregate + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == scoped + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert bool(sim.calculate(status, 2025)[2]) == (mode not in {"nil", "neutralized"}) + + +def test_outside_own_pension_credit_age_input_is_used(): + sim = Simulation( + situation=household( + 2027, + outside={ + "income_support_reported": 1_000, + "has_attained_state_pension_credit_qualifying_age": True, + "is_SP_age": False, + }, + ) + ) + # An explicit qualifying-age input tests the interface mapping, not a + # new assertion about the legal timetable or actual migration notices. + assert not sim.calculate("person_legacy_benefits_closed", 2027)[2] diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index bf136eaf90..2df7693ab5 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -80,8 +80,13 @@ def award_of_members(benunit, period, award, report, award_on_reports, members): reported = person(report, period) reported_total = benunit.sum(reported) members_reported = benunit.sum(reported * members) - award_on_all_reports = award_on_reports(benunit, period, reported_total) - award_on_members_reports = award_on_reports(benunit, period, members_reported) + # Formula awards omit reports whose own award has closed. Keep the raw + # totals above for disable_simulated_benefits and direct-value provenance. + open_reported = reported * ~person("person_legacy_benefits_closed", period) + award_on_all_reports = award_on_reports(benunit, period, benunit.sum(open_reported)) + award_on_members_reports = award_on_reports( + benunit, period, benunit.sum(open_reported * members) + ) # Compare in the precision the award is stored in (float32), so the # formula's own award always matches the award recomputed here. stored = stored_award.dtype diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 21b8f01b5d..cee5d2c28a 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -53,28 +53,14 @@ def own_report_is_paid(person, period, award, works, capital_limit): cannot stand in for theirs, because when the claimant or partner reports an award it tests only them. - The family's legacy benefits must not have closed - (legacy_benefits_closed). The model ends every award in the benefit unit - once they close, and so the benefit unit's award; this person's status - follows it. That is a simplification: in law the migration notice goes - to the claimant and partner (SI 2014/1230 reg 44(1), (4)) and ends their - awards (reg 46(1)), not the award of a member who claims in their own - right. The benefit unit's award must also be in payment: positive, or - nil while the benefit unit's screen fails and the award is not - neutralised. When the award on this person's report alone is positive - and the family's legacy benefits are open, that screen fails only when - the claimant or partner reports an award and fails the work tests, - which leaves this person's own claim standing; on the formula path that - is the only way the award is nil. A zero cannot say why it is zero, so - a reform that replaces the award with nil, or a nil award entered - directly, removes the status only while the screen passes. A reform - that neutralises the benefit always removes it. - - Apart from the closure, on the formula path another member's report or - work never changes this. Their own tests read only their own work, - their own report and the household's capital, and when the award on - their report alone is positive, the benefit unit's award is positive - exactly when its screen passes.""" + Their own legacy benefits must remain open under the existing annual + proxy (person_legacy_benefits_closed). A couple's closure does not end + this separate claim. The benefit unit's award must also be in payment: + positive, or nil while its work/capital screen fails and the award is not + neutralised. A directly entered or reformed nil removes status while + that screen passes; neutralising the award always removes it. These + existing value-based override rules and shared-capital proxy are retained. + """ benunit = person.benunit own_award = ( ~works @@ -84,10 +70,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): > benunit(f"{award}_tariff_income", period) ) ) - # The screen also fails once the family's legacy benefits have closed; - # the model then ends every member's award (a simplification: SI - # 2014/1230 reg 46(1) ends only the claimant's and partner's). - closed = benunit("legacy_benefits_closed", period) + closed = person("person_legacy_benefits_closed", period) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 0f6d2d37fa..7b723bf4c6 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -23,5 +23,9 @@ class esa_income(Variable): unit = GBP def formula(benunit, period, parameters): - reported_award = add(benunit, period, ["esa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("esa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 031fa6c671..c071f77373 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -26,8 +26,9 @@ class esa_income_eligible(Variable): "in it, so when the claimant or partner reports, another member's " "report is paid or not with their claim. Whether that member is on " "the award themselves (is_on_income_related_esa) is tested on their " - "own claim. The award also ends once one of the family's legacy " - "benefits has closed (legacy_benefits_closed). This is not a full " + "own claim. Reports whose owner has closed legacy benefits are left " + "out before choosing candidates (person_legacy_benefits_closed). " + "This is not a full " "entitlement model." ) definition_period = YEAR @@ -43,7 +44,9 @@ def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income person = benunit.members capital = benunit("esa_income_assessable_capital", period) - reports_award = person("esa_income_reported", period) > 0 + reports_award = (person("esa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -65,11 +68,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~claimant_works & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-related ESA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= ESA.capital.limit) + return benunit.any(claimant) & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 8e91573713..1b701f64a5 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -25,10 +25,9 @@ class is_on_income_based_jsa(Variable): "positive, the report exceeding the tariff income from the benefit " "unit's capital, within the capital limit. On the formula path the " "claimant's or partner's claim and work never change this. Income-based " - "JSA must be open (gov.dwp.JSA.income.active), the family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "JSA must be open (gov.dwp.JSA.income.active), the legacy " + "benefits for this person's own award must remain open " + "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while jsa_income_eligible fails and " "jsa_income is not neutralised. On the formula path that nil comes only " "from the claimant's or partner's own claim failing the screen. " diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index 0df7276e1a..cd3c0173d5 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -24,10 +24,9 @@ class is_on_income_related_esa(Variable): "para 6(1)(e)), and the award on their own report alone is positive, " "the report exceeding the tariff income from the benefit unit's " "capital, within the capital limit. On the formula path the claimant's " - "or partner's claim and work never change this. The family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "or partner's claim and work never change this. The legacy " + "benefits for this person's own award must remain open " + "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while esa_income_eligible fails and " "esa_income is not neutralised. On the formula path that nil comes only " "from the claimant's or partner's own claim failing the screen. " diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index beae7d3f99..34a3c0e2a5 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -28,5 +28,9 @@ class jsa_income(Variable): def formula(benunit, period, parameters): if not parameters(period).gov.dwp.JSA.income.active: return benunit.empty_array() - reported_award = add(benunit, period, ["jsa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("jsa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_jsa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py index 505d1c47b1..8d26015795 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py @@ -31,8 +31,8 @@ class jsa_income_eligible(Variable): "reports, another member's report is paid or not with their claim. " "Whether that member is on the award themselves " "(is_on_income_based_jsa) is tested on their own claim. The award " - "also ends once one of the family's legacy benefits has closed " - "(legacy_benefits_closed). This is not a full entitlement model." + "excludes reports whose owner has closed legacy benefits before choosing " + "candidates (person_legacy_benefits_closed). This is not a full entitlement model." ) definition_period = YEAR reference = ( @@ -48,7 +48,9 @@ def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA person = benunit.members capital = benunit("jsa_income_assessable_capital", period) - reports_award = person("jsa_income_reported", period) > 0 + reports_award = (person("jsa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -67,11 +69,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~works_as_claimant & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-based JSA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= JSA.income.capital.limit) + return benunit.any(claimant) & (capital <= JSA.income.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py index fec4ccaa0d..51e8d1889f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -1,13 +1,52 @@ from policyengine_uk.model_api import * +def legacy_award_is_closed(reports, pension_age_route, mixed_age_couple, dwp): + """Existing annual migration proxy for one award's owners. + + This preserves the model's programme closure dates and Pension Credit + exceptions. It does not simulate notices, deadlines or run-on periods. + """ + on_pension_credit = reports("pension_credit_reported") + # A tax credit closure notice sent to Pension Credit, not Universal + # Credit, a family on Pension Credit, or one on Child Tax Credit but + # not Working Tax Credit that meets the Pension Credit age conditions + # (single, both pension age, or a protected mixed-age couple; SI + # 2019/167 art. 3A(1)). Every other tax credit family, including a + # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a + # Universal Credit migration notice (reg 44). + pension_credit_route = on_pension_credit | ( + pension_age_route & ~reports("working_tax_credit_reported") + ) + tax_credits_closed = ( + reports("child_tax_credit_reported", "working_tax_credit_reported") + & (not dwp.tax_credits.active) + & ~pension_credit_route + ) + # Income Support and income-based JSA are working-age benefits; the + # rules ending legacy awards on a Universal Credit claim do not apply + # to a single claimant, or joint claimants both, over the qualifying + # age for State Pension Credit (reg 8(2B)). + wholly_pension_age = pension_age_route & ~mixed_age_couple + income_support_closed = reports("income_support_reported") & ( + not dwp.income_support.active + ) + jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) + return tax_credits_closed | ( + (income_support_closed | jsa_income_closed) + & ~on_pension_credit + & ~wholly_pension_age + ) + + class legacy_benefits_closed(Variable): value_type = bool entity = BenUnit label = "Moved off legacy benefits onto the Universal Credit route" documentation = ( - "Whether DWP has moved this family off its legacy means-tested " - "benefits towards Universal Credit because one it reports has closed: " + "Annual proxy for whether DWP has moved the claimant and partner off " + "their legacy means-tested " + "benefits towards Universal Credit because one they report has closed: " "tax credits from 6 April 2025, and Income Support and income-based " "Jobseeker's Allowance from 1 April 2026 (their active parameters). " "The migration notice covers all the family's legacy awards, so they " @@ -19,7 +58,9 @@ class legacy_benefits_closed(Variable): "Tax Credit are included; Income Support and income-based JSA " "closures do not move a wholly pension-age family. Housing Benefit and income-related " "Employment and Support Allowance join this list when the model ends " - "their working-age awards." + "their working-age awards. Reports by other benefit-unit members do " + "not close or shield this award. This annual proxy does not model " + "actual notices, deadlines, run-on periods or Housing Benefit exceptions." ) definition_period = YEAR reference = ( @@ -33,39 +74,16 @@ class legacy_benefits_closed(Variable): ) def formula(benunit, period, parameters): - dwp = parameters(period).gov.dwp + person = benunit.members + owners = person("is_claimant_or_partner", period) def reports(*variables): - return add(benunit, period, list(variables)) > 0 + amounts = sum(person(name, period) for name in variables) + return benunit.sum(amounts * owners) > 0 - on_pension_credit = reports("pension_credit_reported") - pension_age_route = benunit("meets_pension_credit_age_conditions", period) - # A tax credit closure notice sent to Pension Credit, not Universal - # Credit, a family on Pension Credit, or one on Child Tax Credit but - # not Working Tax Credit that meets the Pension Credit age conditions - # (single, both pension age, or a protected mixed-age couple; SI - # 2019/167 art. 3A(1)). Every other tax credit family, including a - # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a - # Universal Credit migration notice (reg 44). - pension_credit_route = on_pension_credit | ( - pension_age_route & ~reports("working_tax_credit_reported") - ) - tax_credits_closed = ( - reports("child_tax_credit_reported", "working_tax_credit_reported") - & (not dwp.tax_credits.active) - & ~pension_credit_route - ) - # Income Support and income-based JSA are working-age benefits; the - # rules ending legacy awards on a Universal Credit claim do not apply - # to a single claimant, or joint claimants both, over the qualifying - # age for State Pension Credit (reg 8(2B)). - wholly_pension_age = pension_age_route & ~benunit("is_mixed_age_couple", period) - income_support_closed = reports("income_support_reported") & ( - not dwp.income_support.active - ) - jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) - return tax_credits_closed | ( - (income_support_closed | jsa_income_closed) - & ~on_pension_credit - & ~wholly_pension_age + return legacy_award_is_closed( + reports, + benunit("meets_pension_credit_age_conditions", period), + benunit("is_mixed_age_couple", period), + parameters(period).gov.dwp, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py new file mode 100644 index 0000000000..9276abf287 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.legacy_benefits_closed import ( + legacy_award_is_closed, +) + + +class person_legacy_benefits_closed(Variable): + value_type = bool + entity = Person + label = "Legacy benefits closed for this person's award" + documentation = ( + "The existing annual legacy-closure proxy, scoped to the award owner. " + "The claimant and partner share legacy_benefits_closed. Other members " + "claiming in their own right use only their own reports and Pension " + "Credit qualifying age. Their reports neither close nor shield the " + "couple's award, and the couple's reports do not close theirs. No new " + "notice, deadline, run-on or legal relationship inference is made." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + ) + + def formula(person, period, parameters): + def reports(*variables): + return sum(person(name, period) for name in variables) > 0 + + pension_age = person("has_attained_state_pension_credit_qualifying_age", period) + own_closed = legacy_award_is_closed( + reports, pension_age, pension_age & False, parameters(period).gov.dwp + ) + return where( + person("is_claimant_or_partner", period), + person.benunit("legacy_benefits_closed", period), + own_closed, + ) From ecd531776efeb15f30678ce2f143c773c202f251 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:04:24 +0000 Subject: [PATCH 129/141] Update package version --- CHANGELOG.md | 12 ++++++++++++ changelog.d/2542.fixed.md | 2 -- changelog.d/2542.removed.md | 1 - pyproject.toml | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) delete mode 100644 changelog.d/2542.fixed.md delete mode 100644 changelog.d/2542.removed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab2f96796..f4a09ef6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2.145.0] - 2026-10-21 + +### Fixed + +- - Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. + - Cap changes are priced from a three-band table of uncapped single fares (`gov.dft.bus.uncapped_single_fares`), fitted to DfT's evaluation of the £2 cap, the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for the £3 cap in 2025 and the £2 cap in 2027, and uprated with CPI, instead of one £2.748 fare for every single ticket. A £3 cap now binds, and a full year at £2 against no cap saves about £0.36bn a year at 2024-25 boardings instead of £0.70bn. A supplied `local_bus_uncapped_single_fare` replaces the table for that person. + +### Removed + +- - Removed `gov.dft.bus.reference_year` and `gov.dft.bus.uncapped_single_fare`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`; the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year; and cap changes are priced from `gov.dft.bus.uncapped_single_fares`. A reform naming a removed parameter fails with directions. + + ## [2.144.0] - 2026-10-21 ### Added diff --git a/changelog.d/2542.fixed.md b/changelog.d/2542.fixed.md deleted file mode 100644 index 99f62e1071..0000000000 --- a/changelog.d/2542.fixed.md +++ /dev/null @@ -1,2 +0,0 @@ -- Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. -- Cap changes are priced from a three-band table of uncapped single fares (`gov.dft.bus.uncapped_single_fares`), fitted to DfT's evaluation of the £2 cap, the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for the £3 cap in 2025 and the £2 cap in 2027, and uprated with CPI, instead of one £2.748 fare for every single ticket. A £3 cap now binds, and a full year at £2 against no cap saves about £0.36bn a year at 2024-25 boardings instead of £0.70bn. A supplied `local_bus_uncapped_single_fare` replaces the table for that person. diff --git a/changelog.d/2542.removed.md b/changelog.d/2542.removed.md deleted file mode 100644 index 918c987f03..0000000000 --- a/changelog.d/2542.removed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed `gov.dft.bus.reference_year` and `gov.dft.bus.uncapped_single_fare`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`; the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year; and cap changes are priced from `gov.dft.bus.uncapped_single_fares`. A reform naming a removed parameter fails with directions. diff --git a/pyproject.toml b/pyproject.toml index f1522980e4..31be88c9fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.144.0" +version = "2.145.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 02f80240fb10384956cc6dcb1d276c45302491ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:24:04 +0100 Subject: [PATCH 130/141] Fix issues from review: preserve reported legacy awards --- changelog.d/2395.fixed.md | 2 + .../disable_simulated_benefits.py | 3 +- .../tests/test_disable_simulated_benefits.py | 44 ++++++++++++++----- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/changelog.d/2395.fixed.md b/changelog.d/2395.fixed.md index 7d3dce9d6d..d00bf88801 100644 --- a/changelog.d/2395.fixed.md +++ b/changelog.d/2395.fixed.md @@ -1 +1,3 @@ Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. + +Keep each person's legacy closure false alongside the family closure when the report-only benefit reform holds migration off. diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index fc893866ab..15b042658e 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -72,7 +72,8 @@ # Benefit or tax credits. An adult outside the couple who reports # income-based JSA is on it, as their own claim, only in the years the model # has the scheme open (gov.dwp.JSA.income.active: to 2025). -NOT_MOVED = ["legacy_benefits_closed"] +# Hold the family closure and each separate member's own closure. +NOT_MOVED = ["legacy_benefits_closed", "person_legacy_benefits_closed"] # The first year in which the model pays a carer in Scotland Carer Support # Payment in place of Carer's Allowance: carers_allowance_pre_overlap and diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 079089376a..1becfb4bc7 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -313,17 +313,31 @@ def test_the_council_tax_reduction_passport(reformed, year): @pytest.mark.parametrize("year", YEARS) def test_no_family_is_moved_off_its_legacy_benefits(reformed, unreformed, year): - # Income-based JSA and Income Support close from April 2026, and the - # model then moves a working-age family reporting either, unless it is on - # Pension Credit, off all its legacy benefits (legacy_benefits_closed), so - # their awards end. Every family here reports one of them. The reform pays every reported award in every - # year, so it holds the move off; otherwise an adult outside the couple - # would stop being on the ESA the reform still pays them, and on its JSA - # while income-based JSA is open (test_each_person_is_on_their_own_award). + # The annual closure proxy follows each award's owners (SI 2014/1230 + # regs 44 and 46). Only the excluded adult reports a closing benefit in + # excluded_adult_no_award and excluded_income_support, so their reports + # never close the claimant's award. The other claimants report Income + # Support, which closes from 2026 under the existing model parameters. moved = year > DATASET_YEAR - assert by_family(unreformed, "legacy_benefits_closed", year) == dict.fromkeys( - NAMES, moved - ) + assert by_family(unreformed, "legacy_benefits_closed", year) == { + "excluded_adult": moved, + "excluded_adult_no_award": False, + "own_esa": moved, + "own_jsa": moved, + "own_esa_over_tariff": moved, + "excluded_income_support": False, + } + assert by_person(unreformed, "person_legacy_benefits_closed", year) == { + "excluded_adult_0": moved, + "excluded_adult_1": moved, + "excluded_adult_no_award_0": False, + "excluded_adult_no_award_1": moved, + "own_esa_0": moved, + "own_jsa_0": moved, + "own_esa_over_tariff_0": moved, + "excluded_income_support_0": False, + "excluded_income_support_1": moved, + } assert by_family(reformed, "legacy_benefits_closed", year) == dict.fromkeys( NAMES, False ) @@ -792,3 +806,13 @@ def member(age, claimant_or_partner_in_first_year, **reported): == claimant_or_partner_esa ) assert simulation.calculate("income_support", year)[0] == income_support + + +@pytest.mark.parametrize("year", range(DATASET_YEAR, DATASET_YEAR + YEARS_IN_FUTURE)) +def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): + # The reform pays reported awards for all ten years, including an adult's + # own award outside the couple. Both award owners' closure controls must + # remain false; holding only the claimant's closure leaves that adult's + # reported ESA in household income while incorrectly ending their status. + closed = by_person(reformed, "person_legacy_benefits_closed", year) + assert closed == dict.fromkeys(closed, False) From 0878d8d6eb18bd66698b0992058f5dd29a8d0ee5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:41:03 +0000 Subject: [PATCH 131/141] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/1941.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/1941.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f4a09ef6e4..e927d6e6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.145.1] - 2026-10-21 + +### Fixed + +- - Uprate additional State Pension and protected payments by September CPI, as the Up-rating Orders do (SI 2026/148 arts 4(3) and 6(3)(b)), not by the full rate's triple-lock rise; reforms to the full rates no longer move them. + + ## [2.145.0] - 2026-10-21 ### Fixed diff --git a/changelog.d/1941.fixed.md b/changelog.d/1941.fixed.md deleted file mode 100644 index 51bd32d483..0000000000 --- a/changelog.d/1941.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Uprate additional State Pension and protected payments by September CPI, as the Up-rating Orders do (SI 2026/148 arts 4(3) and 6(3)(b)), not by the full rate's triple-lock rise; reforms to the full rates no longer move them. diff --git a/pyproject.toml b/pyproject.toml index 31be88c9fb..0ba29adede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.145.0" +version = "2.145.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 7918fc2a75efbc1e127cfa61967b53bf9d51bb85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:09:35 +0000 Subject: [PATCH 132/141] Update package version --- CHANGELOG.md | 10 ++++++++++ changelog.d/1818.fixed.md | 1 - changelog.d/1836.fixed.md | 1 - changelog.d/2283.fixed.md | 1 - changelog.d/2476-participation-couple-year.fixed.md | 1 - pyproject.toml | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) delete mode 100644 changelog.d/1818.fixed.md delete mode 100644 changelog.d/1836.fixed.md delete mode 100644 changelog.d/2283.fixed.md delete mode 100644 changelog.d/2476-participation-couple-year.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e927d6e6fd..fea8481707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2.145.2] - 2026-10-22 + +### Fixed + +- Preserve cohabiting-couple participation groups when calculating responses for an explicit year. +- - Added Universal Credit benefit cap earnings exception regression tests (Universal Credit Regulations 2013 reg. 82), including the per-claimant earned income cases (tax on pension income, one partner's pension contributions, contributions after 75), and cited regs. 55 and 57 and Finance Act 2004 s. 188 on `is_uc_benefit_cap_exempt_earnings`. +- Use the requested year and eligible-only spillover bins in participation dynamics, preserving seeded MT19937 draws without changing global NumPy state. +- When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor. + + ## [2.145.1] - 2026-10-21 ### Fixed diff --git a/changelog.d/1818.fixed.md b/changelog.d/1818.fixed.md deleted file mode 100644 index 58264dbc8e..0000000000 --- a/changelog.d/1818.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Added Universal Credit benefit cap earnings exception regression tests (Universal Credit Regulations 2013 reg. 82), including the per-claimant earned income cases (tax on pension income, one partner's pension contributions, contributions after 75), and cited regs. 55 and 57 and Finance Act 2004 s. 188 on `is_uc_benefit_cap_exempt_earnings`. diff --git a/changelog.d/1836.fixed.md b/changelog.d/1836.fixed.md deleted file mode 100644 index 872e66d6cc..0000000000 --- a/changelog.d/1836.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Use the requested year and eligible-only spillover bins in participation dynamics, preserving seeded MT19937 draws without changing global NumPy state. diff --git a/changelog.d/2283.fixed.md b/changelog.d/2283.fixed.md deleted file mode 100644 index 0285a05397..0000000000 --- a/changelog.d/2283.fixed.md +++ /dev/null @@ -1 +0,0 @@ -When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor. diff --git a/changelog.d/2476-participation-couple-year.fixed.md b/changelog.d/2476-participation-couple-year.fixed.md deleted file mode 100644 index 0b9e19ebf2..0000000000 --- a/changelog.d/2476-participation-couple-year.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Preserve cohabiting-couple participation groups when calculating responses for an explicit year. diff --git a/pyproject.toml b/pyproject.toml index 0ba29adede..3083a8e537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.145.1" +version = "2.145.2" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From a8b2848c133702d5edac587b0f238e68c8785a45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:18:39 +0000 Subject: [PATCH 133/141] Update package version --- CHANGELOG.md | 13 +++++++++++++ changelog.d/2395.fixed.md | 3 --- changelog.d/council-tax-computed-liability.added.md | 1 - pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) delete mode 100644 changelog.d/2395.fixed.md delete mode 100644 changelog.d/council-tax-computed-liability.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fea8481707..904d4fe883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2.146.0] - 2026-10-22 + +### Added + +- Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. Band D amounts cover all 350 April 2023 local authorities across England, Wales and Scotland for fiscal 2025 and 2026. + +### Fixed + +- Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. + + Keep each person's legacy closure false alongside the family closure when the report-only benefit reform holds migration off. + + ## [2.145.2] - 2026-10-22 ### Fixed diff --git a/changelog.d/2395.fixed.md b/changelog.d/2395.fixed.md deleted file mode 100644 index d00bf88801..0000000000 --- a/changelog.d/2395.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. - -Keep each person's legacy closure false alongside the family closure when the report-only benefit reform holds migration off. diff --git a/changelog.d/council-tax-computed-liability.added.md b/changelog.d/council-tax-computed-liability.added.md deleted file mode 100644 index f91130b7ac..0000000000 --- a/changelog.d/council-tax-computed-liability.added.md +++ /dev/null @@ -1 +0,0 @@ -Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. Band D amounts cover all 350 April 2023 local authorities across England, Wales and Scotland for fiscal 2025 and 2026. diff --git a/pyproject.toml b/pyproject.toml index 3083a8e537..9bc3d83dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.145.2" +version = "2.146.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 582b86315cec645255b53c64665adddfe475e19b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:59:00 +0000 Subject: [PATCH 134/141] Update package version --- CHANGELOG.md | 20 +++++++++++++++++++ changelog.d/1997.fixed.md | 3 --- .../2121-savings-credit-followup.changed.md | 1 - .../222-ni-scale-parameters.changed.md | 4 ---- changelog.d/2345.fixed.md | 1 - changelog.d/2356.fixed.md | 1 - changelog.d/2473-main-ni-reader.fixed.md | 1 - pyproject.toml | 2 +- 8 files changed, 21 insertions(+), 12 deletions(-) delete mode 100644 changelog.d/1997.fixed.md delete mode 100644 changelog.d/2121-savings-credit-followup.changed.md delete mode 100644 changelog.d/222-ni-scale-parameters.changed.md delete mode 100644 changelog.d/2345.fixed.md delete mode 100644 changelog.d/2356.fixed.md delete mode 100644 changelog.d/2473-main-ni-reader.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 904d4fe883..ff172721f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [2.146.1] - 2026-10-22 + +### Changed + +- - Store marginal National Insurance schedules as parameter scales while preserving contribution calculations and existing scalar reform paths. + - Reject unsupported changes to the fixed zero-rate, zero-threshold NI scale sentinel with a targeted error. + - Direct YAML readers must migrate the ten removed scalar files to the canonical scale files; parameter-tree readers retain the legacy compatibility paths. + - Retain incoming savings indexation, director and liability rules while migrating NI readers; hide legacy and fixed-zero discovery controls without removing their IDs or update contracts. +- - Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata. + +### Fixed + +- Preserve age-conditioned National Insurance deductions when validating Carer Support Payment earnings after main integration. +- Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. + + Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified. +- - Applied the Carer Support Payment earnings limit, including its own dated thresholds and favorable fractional-penny rule. +- Fix the default historical income-tax band thresholds and strict upper boundaries, including Marriage Allowance eligibility in 2016 and 2017. + + ## [2.146.0] - 2026-10-22 ### Added diff --git a/changelog.d/1997.fixed.md b/changelog.d/1997.fixed.md deleted file mode 100644 index bcc1b4fd30..0000000000 --- a/changelog.d/1997.fixed.md +++ /dev/null @@ -1,3 +0,0 @@ -Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. - -Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified. diff --git a/changelog.d/2121-savings-credit-followup.changed.md b/changelog.d/2121-savings-credit-followup.changed.md deleted file mode 100644 index 7114380da5..0000000000 --- a/changelog.d/2121-savings-credit-followup.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata. diff --git a/changelog.d/222-ni-scale-parameters.changed.md b/changelog.d/222-ni-scale-parameters.changed.md deleted file mode 100644 index afed05cb16..0000000000 --- a/changelog.d/222-ni-scale-parameters.changed.md +++ /dev/null @@ -1,4 +0,0 @@ -- Store marginal National Insurance schedules as parameter scales while preserving contribution calculations and existing scalar reform paths. -- Reject unsupported changes to the fixed zero-rate, zero-threshold NI scale sentinel with a targeted error. -- Direct YAML readers must migrate the ten removed scalar files to the canonical scale files; parameter-tree readers retain the legacy compatibility paths. -- Retain incoming savings indexation, director and liability rules while migrating NI readers; hide legacy and fixed-zero discovery controls without removing their IDs or update contracts. diff --git a/changelog.d/2345.fixed.md b/changelog.d/2345.fixed.md deleted file mode 100644 index 2644dc96ca..0000000000 --- a/changelog.d/2345.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Applied the Carer Support Payment earnings limit, including its own dated thresholds and favorable fractional-penny rule. diff --git a/changelog.d/2356.fixed.md b/changelog.d/2356.fixed.md deleted file mode 100644 index a14daae927..0000000000 --- a/changelog.d/2356.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix the default historical income-tax band thresholds and strict upper boundaries, including Marriage Allowance eligibility in 2016 and 2017. diff --git a/changelog.d/2473-main-ni-reader.fixed.md b/changelog.d/2473-main-ni-reader.fixed.md deleted file mode 100644 index fe499acfe7..0000000000 --- a/changelog.d/2473-main-ni-reader.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Preserve age-conditioned National Insurance deductions when validating Carer Support Payment earnings after main integration. diff --git a/pyproject.toml b/pyproject.toml index 9bc3d83dd0..e304484714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.146.0" +version = "2.146.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 9d81a5dfa8bd679f2ade4acbcd215a69f03b3cc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:59:49 +0000 Subject: [PATCH 135/141] Update package version --- CHANGELOG.md | 8 ++++++++ changelog.d/1793.fixed.md | 1 - changelog.d/2474-combined-csp-gates.fixed.md | 1 - pyproject.toml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/1793.fixed.md delete mode 100644 changelog.d/2474-combined-csp-gates.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ff172721f5..a141f5f9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2.146.2] - 2026-10-22 + +### Fixed + +- Retain both the minimum-age and earnings conditions when combining Carer Support Payment fixes. +- Applied the minimum ages to computed Carer's Allowance, Carer Support Payment and DLA mobility awards, preserving DLA care and explicit amount overrides. + + ## [2.146.1] - 2026-10-22 ### Changed diff --git a/changelog.d/1793.fixed.md b/changelog.d/1793.fixed.md deleted file mode 100644 index 167067ae56..0000000000 --- a/changelog.d/1793.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Applied the minimum ages to computed Carer's Allowance, Carer Support Payment and DLA mobility awards, preserving DLA care and explicit amount overrides. diff --git a/changelog.d/2474-combined-csp-gates.fixed.md b/changelog.d/2474-combined-csp-gates.fixed.md deleted file mode 100644 index c20958307b..0000000000 --- a/changelog.d/2474-combined-csp-gates.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Retain both the minimum-age and earnings conditions when combining Carer Support Payment fixes. diff --git a/pyproject.toml b/pyproject.toml index e304484714..7ca01757bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.146.1" +version = "2.146.2" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 682a98f8ad49338a51ba11e9b453a7bbe6c2807b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:04:27 -0400 Subject: [PATCH 136/141] Test the local Universal Credit exemption on each person's own earnings, and #2017's head-alone couple Para 30(8)(c) of each local scheme exempts a non-dependant entitled to Universal Credit "calculated on the basis that the person does not have any earned income". The merge reads it on the person's own earnings, as #2007's national schemes read the same words, so the non-earning member of a UC couple is exempt: Merton's couple pays 691.60, not 962 (CTR 1,108.40), and Newham's partner's own deduction is nil (the couple's one deduction, the worker's, is unchanged). Main's IIDB case for a grandmother who applies alone noted that the model charged nothing for the couple in her benefit unit until #2017. With #2017 they are her non-dependants: one deduction, the lowest (5.20 a week), so council_tax_benefit is 1,195.968. Co-Authored-By: Claude Opus 5.5 --- ...cil_tax_reduction_prescribed_benefits.yaml | 12 ++++++--- .../local_scheme_non_dependants.yaml | 27 +++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index ea63047128..ee29ceeca5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -570,10 +570,14 @@ # A single pensioner who reached pensionable age before April 2021: # 256.00 * 52 = 13,312.00. council_tax_reduction_applicable_amount: 13_312 - # 1,800 - 0.2 x (14,980.16 - 13,312.00) = 1,800 - 333.632 = 1,466.368. - # The couple are in her benefit unit, so the model charges no - # non-dependant deduction for them (#2017). - council_tax_benefit: 1_466.368 + # 1,800 - 0.2 x (14,980.16 - 13,312.00) = 1,800 - 333.632 = 1,466.368, + # less a non-dependant deduction: the couple are in her benefit unit but + # not her family, so are her non-dependants (SI 2012/2885 reg 9; #2017). + # They pay one deduction, the higher (Sch 1 para 8(3)); neither is in + # remunerative work, so each would pay the lowest amount, 5.20 a week + # (para 8(1)(b)): 5.20 x 52 = 270.40. 1,466.368 - 270.40 = 1,195.968. + council_tax_reduction_non_dep_deductions: 270.40 + council_tax_benefit: 1_195.968 - name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml index 72d41a926c..64686f3ab5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -13,7 +13,9 @@ # higher (para 30(3)-(4); Oxford para 44), except that Merton and Kingston # upon Thames deduct for each member of a couple with a Universal Credit # award. The benefit-receipt exemptions (para 30(8)) apply to the person on -# the award: a family's award is its claimant's and partner's. +# the award: Income Support, income-based JSA, income-related ESA and State +# Pension Credit to the member they are payable to, and Universal Credit to +# each joint claimant who has no earned income of their own. # # Weekly amounts for 2026-27 (52 weeks a year): # - Merton (para 30) and Westminster (Appendix A), Oxford (para 43): £5.20 not @@ -214,7 +216,7 @@ merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] merton_council_tax_reduction_non_dep_deductions: [691.60, 0] -- name: Merton deducts for each member of a Universal Credit couple on their own hours +- name: Merton deducts for each member of a Universal Credit couple on their own hours and earnings period: 2026 absolute_error_margin: 0.01 input: @@ -248,12 +250,14 @@ savings: 0 output: # Para 30(3): no single deduction where there is a UC award. The worker - # pays £13.30 on the couple's joint £576.92; the partner, not in - # remunerative work, £5.20. The joint award has earned income, so - # neither is exempt under para 30(8)(c). - merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 270.40] - merton_council_tax_reduction_non_dep_deductions: [962, 0] - council_tax_reduction: 838 + # pays £13.30 on the couple's joint £576.92. Para 30(8)(c) exempts one + # entitled to UC "calculated on the basis that the person does not have + # any earned income", read on each person's own earnings as in the + # national schemes: the partner has none, so pays nothing; the worker is + # not exempt. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 0] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 - name: Merton makes one deduction for a couple not on Universal Credit, on joint income period: 2026 @@ -496,9 +500,10 @@ council_tax: 1_800 savings: 0 output: - # Para 30B(3) has no Universal Credit exception: the higher of £22.61 - # and £7.54. - newham_council_tax_reduction_individual_non_dep_deduction: [0, 1_175.72, 392.08] + # Para 30B(3) has no Universal Credit exception: the higher of the two, + # the worker's £22.61. The partner, with no earned income of her own, is + # exempt under para 30B's Universal Credit exemption. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 1_175.72, 0] newham_council_tax_reduction_non_dep_deductions: [1_175.72, 0] council_tax_reduction: 84.28 From f4589b9e94b7bdc26af8ced2fd30e267b85bc676 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:04:40 -0400 Subject: [PATCH 137/141] Exclude a non-dependant personally only as a joint tenant or a lodger outside the head's family Reg 9(2)(d)-(e) exclude the person, but reg 9(3)(a) keeps as a non-dependant anyone who pays a close relative of the applicant or partner, or pays on a non-commercial basis. The model has no relationship input, so a member of the household head's own family who pays board is taken to be a close relative and stays a non-dependant; anyone else who pays the householder is excluded (Sol review r1 finding 2, maintainer point 6). The gap for a close relative in another family is documented in the variable. The household head and anyone liable for the rent are no longer excluded wholesale: #2017's in-unit set is built on the CTR applicant, so an applicant is never in it, and is_liable_for_household_rent marks the young couple of a grandmother who applies alone as liable, which wrongly excluded them (maintainer point 3). A joint tenant who is not their unit's claimant or partner (an input) is still excluded. A student head whose family cannot claim stays the co-tenant's non-dependant, as main's case has it. No raw is_household_head read remains. Co-Authored-By: Claude Opus 5.5 --- ...n_individual_non_dep_deduction_eligible.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 148510528b..6a8608c938 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -18,9 +18,9 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): "liable for the council tax with the applicant (a joint tenant or " "other sharer of the rent) and a boarder or lodger who pays the " "applicant are not non-dependants. Those exclusions belong to the " - "person, whatever family they are in: a person the model treats as " - "liable for the council tax (council_tax_reduction_liable_person), " - "and a person outside the household head's family who pays the " + "person, whatever family they are in: a joint tenant liable for the " + "rent who is not their benefit unit's claimant or partner, and a " + "person outside the household head's family who pays the " "householder for their own board or lodging, is nobody's " "non-dependant. A member of the household head's own family who pays " "board is taken to be a close relative of the head, so remains a " @@ -47,9 +47,11 @@ def formula(person, period, parameters): in_unit = person( "is_benefit_unit_non_dependant_for_council_tax_reduction", period ) - # Reg 9(2)(d)-(e) exclude the person, not only their family. Anyone - # treated as liable for the council tax (LGFA 1992 s.6) is jointly - # and severally liable with the applicant. Anyone who pays the + # Reg 9(2)(d)-(e) exclude the person, not only their family. A joint + # tenant (the same interest in the dwelling, LGFA 1992 s.6) is + # jointly and severally liable with the applicant, even one who is + # not their benefit unit's claimant or partner; the applicants and + # partners of liable families are never in_unit. Anyone who pays the # householder for their own board or lodging is liable to make # payments on a commercial basis, even where the rest of their family # pays nothing, unless they are a close relative of the applicant or @@ -61,9 +63,10 @@ def formula(person, period, parameters): add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) > 0 ) in_head_family = person.benunit("benunit_contains_household_head", period) - personally_excluded = person("council_tax_reduction_liable_person", period) | ( - pays_householder & ~in_head_family + joint_tenant = person("is_liable_for_household_rent", period) & ~person( + "is_claimant_or_partner", period ) + personally_excluded = joint_tenant | (pays_householder & ~in_head_family) return ( (person("age", period) >= 18) & (other_family | in_unit) From 187a0596b8545ef0dff81ab47a7d1e7fa4f9391d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:04:40 -0400 Subject: [PATCH 138/141] Deduct for each member of a Universal Credit couple in Westminster, as the Default Scheme does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Westminster publishes only a summary of its working-age scheme, which states that the Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 form its basis, and the Appendix A scale. The Default Scheme's para 30(3) makes one deduction for a couple "(other than where there is an award of universal credit)", as Merton's and Kingston upon Thames's full texts do. With no Westminster provision departing from it, the model follows the Default Scheme (Sol review r1 finding 4, d1398 part 2, logged in RULINGS.md). A YAML case pins it: a UC couple, one earning £30,000 at 37.5 hours and one £2,600 at 10 hours, pays £15.95 + £5.20 a week (1,099.80 a year). The property oracle's Westminster entry follows. Co-Authored-By: Claude Opus 5.5 --- .../local_scheme_non_dependants.yaml | 51 +++++++++++++++++-- ...t_benefit_unit_non_dependant_properties.py | 17 ++++--- ...ouncil_tax_reduction_non_dep_deductions.py | 14 +++++ 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml index 64686f3ab5..704a355129 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -10,9 +10,10 @@ # Each non-dependant aged 18 or over is banded on their own normal gross weekly # income and is in remunerative work on their own hours (para 30(1)-(2) and # para 10); a couple's income is joint and the couple pays one deduction, the -# higher (para 30(3)-(4); Oxford para 44), except that Merton and Kingston -# upon Thames deduct for each member of a couple with a Universal Credit -# award. The benefit-receipt exemptions (para 30(8)) apply to the person on +# higher (para 30(3)-(4); Oxford para 44), except that Merton, Kingston upon +# Thames and Westminster deduct for each member of a couple with a Universal +# Credit award (the Default Scheme's para 30(3), on which Westminster's +# working-age scheme is based). The benefit-receipt exemptions (para 30(8)) apply to the person on # the award: Income Support, income-based JSA, income-related ESA and State # Pension Credit to the member they are payable to, and Universal Credit to # each joint claimant who has no earned income of their own. @@ -507,6 +508,50 @@ newham_council_tax_reduction_non_dep_deductions: [1_175.72, 0] council_tax_reduction: 84.28 +- name: Westminster deducts for each member of a Universal Credit couple, as the Default Scheme does + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 10 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + # Westminster's working-age scheme is based on the Default Scheme, whose + # para 30(3) makes no single deduction where there is an award of + # Universal Credit. The worker pays £15.95 on the couple's joint £626.92 + # a week (Appendix A); the partner, earning but not in remunerative work, + # £5.20, and is not exempt, having earned income of her own. + westminster_council_tax_reduction_individual_non_dep_deduction: [0, 829.40, 270.40] + westminster_council_tax_reduction_non_dep_deductions: [1_099.80, 0] + council_tax_reduction: 700.20 + - name: Westminster charges the applicant's own non-dependant son period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index 88a92228dd..53dc8714aa 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -32,8 +32,8 @@ Oxford, each non-dependant's deduction and each claiming family's total equal an independent calculation from the council's scale: a claimant or partner banded on the couple's joint earnings and the couple deducted once - at the higher amount (each member in Merton and Kingston upon Thames - when the couple has Universal Credit), any other adult banded + at the higher amount (each member in Merton, Kingston upon Thames and + Westminster when the couple has Universal Credit), any other adult banded on their own earnings, each on their own hours; the benefit-receipt exemptions for the person on the award, Universal Credit's tested on their own earnings (none in Oxford); and the household's pool charged to each @@ -598,7 +598,10 @@ def other_families(amounts): # 5-6. The English working-age local schemes. Each council's weekly scale for # 2026-27 (the parameters' first values, which also apply in 2025): Merton # para 30, Kingston upon Thames para 30A, Newham para 30B, Westminster -# Appendix A and Oxford para 43. +# Appendix A and Oxford para 43. Westminster's working-age scheme is based on +# the Default Scheme (SI 2012/2886), whose para 30(3) deducts for each member +# of a couple with an award of Universal Credit, as Merton's and Kingston upon +# Thames's texts do. LOCAL_SCHEMES = { "MERTON": dict( name="merton", @@ -626,7 +629,7 @@ def other_families(amounts): thresholds=[0, 279, 485, 605], amounts=[5.20, 10.60, 13.30, 15.95], benefit_exemptions=True, - each_uc_couple_member=False, + each_uc_couple_member=True, ), "OXFORD": dict( name="oxford", @@ -790,9 +793,9 @@ def local_oracle(person_rows, family_rows, benefits, on_award, claims, ctr_share on_award["universal_credit"][p] and row["earnings"] <= 0 ) individual[p] = 0 if exempt else weekly * 52 - # One deduction for a couple, the higher, or each member's in Merton and - # Kingston upon Thames where the couple has Universal Credit; any other - # member separately. + # One deduction for a couple, the higher, or each member's in Merton, + # Kingston upon Thames and Westminster where the couple has Universal + # Credit; any other member separately. for f, family in enumerate(family_rows): members = [p for p, row in enumerate(person_rows) if row["family"] == f] couple = [p for p in members if person_rows[p]["claimant_or_partner"]] diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py index feefaa2813..7efecd22ff 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py @@ -8,12 +8,26 @@ class westminster_council_tax_reduction_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "Westminster CTR non-dependent deductions" + documentation = ( + "Westminster's working-age scheme is based on the Council Tax " + "Reduction Schemes (Default Scheme) (England) Regulations 2012, and " + "its published summary sets out only the scale (Appendix A). The " + "Default Scheme's para 30(3) makes one deduction for a couple, the " + "higher, other than where there is an award of Universal Credit, so " + "each member of a non-dependant couple with Universal Credit is " + "deducted for, as in Merton's and Kingston upon Thames's texts." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.westminster.gov.uk/sites/default/files/media/documents/council-tax-support-scheme-for-2026-27.pdf", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + ) def formula(benunit, period, parameters): return local_non_dep_deductions( benunit, period, "westminster_council_tax_reduction_individual_non_dep_deduction", + one_deduction_for_uc_couples=False, ) From aaf01f9685f98203791fdaff275e0774c5aa9f06 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:13:02 -0400 Subject: [PATCH 139/141] Test the close-relative presumption across the schemes and restate the changelog Sol review r1 finding 2 asked for regression coverage across the national and devolved schemes, since the eligibility variable is shared. A son in his mother's benefit unit who pays her board stays her non-dependant in England's pensioner scheme (270.40), Scotland's (288.60), Wales's (348.40) and Merton's (691.60 on his own earnings); a lodger's son who pays the householder himself is excluded in Scotland. The relationship gap is #2559. The changelog now says what changes in the national schemes too (maintainer point 5): who is excluded for paying board or as a joint tenant. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2014-local.fixed.md | 2 +- .../family/benefit_unit_non_dependants.yaml | 121 ++++++++++++++++++ .../local_scheme_non_dependants.yaml | 43 ++++++- 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/changelog.d/2014-local.fixed.md b/changelog.d/2014-local.fixed.md index 129c493722..29cf62eda1 100644 --- a/changelog.d/2014-local.fixed.md +++ b/changelog.d/2014-local.fixed.md @@ -1 +1 @@ -Charge non-dependant deductions in the English working-age local Council Tax Reduction schemes (Merton, Kingston upon Thames, Newham, Westminster, Oxford) for adults in the applicant's own benefit unit, deduct separately for each adult in another family who is not its claimant or partner, band each on their own income and hours, and apply benefit-receipt exemptions only to the award's claimant and partner. +Charge non-dependant deductions in the English working-age local Council Tax Reduction schemes (Merton, Kingston upon Thames, Newham, Westminster, Oxford) as the national schemes do: for adults in the applicant's own benefit unit (the applicant identified as for the means test, so a grandmother who applies alone is charged for the couple in her benefit unit), separately for each adult in another family who is not its claimant or partner, and apportioned between jointly liable applicants. Band a claimant or partner on the couple's joint income and anyone else on their own, each on their own hours; exempt a non-dependant on Income Support, income-based JSA, income-related ESA or State Pension Credit only if the award is payable to them, and one on Universal Credit only if they have no earned income of their own; and deduct for each member of a Universal Credit couple in Westminster, as the Default Scheme does. In every scheme, national and local, someone who pays the householder for their own board or lodging, or is a joint tenant, is not a non-dependant even where the rest of their family is, except a member of the household head's own family who pays board, who is taken to be a close relative. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 61a9153226..55ffc05f16 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -1362,3 +1362,124 @@ output: uc_non_dependants_counted: [false, true] uc_non_dep_deductions: [0, 1_158.60] + +# SI 2012/2885 reg 9(2)(e) excludes someone liable to pay the applicant on a +# commercial basis, but reg 9(3)(a)(i) keeps as a non-dependant one who is a +# close relative of the applicant or partner (#2559 checks the Welsh and +# Scottish equivalents' wording). The model has no relationship +# input: a member of the household head's own family who pays board is taken +# to be a close relative, so stays a non-dependant. The son is not in +# remunerative work, so pays each scheme's lowest amount for 2026-27. +# England's pensioner scheme: 5.20 a week (SI 2012/2885 Sch 1 para 8(1)(b)). +- name: CTR (England pensioners), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 70 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: ENGLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 270.40 + +# Scotland's working-age scheme: 5.55 a week (SSI 2021/249 reg 90). +- name: CTR (Scotland), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: SCOTLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 288.60 + +# Wales's working-age scheme: 6.70 a week (WSI 2013/3029 Sch 6 para 5). +- name: CTR (Wales), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: WALES + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 348.40 + +# Someone outside the household head's family who pays the householder is +# excluded personally (reg 9(2)(e)), even where the rest of their family pays +# nothing; the lodger's son who does not pay is still a non-dependant (see +# "CTR, a lodger's adult son is the applicant's non-dependant"). +- name: CTR (Scotland), a lodger's adult son who pays the householder himself is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_lodger: 2_600 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + country: SCOTLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + council_tax_reduction_non_dep_deductions: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml index 704a355129..4b6b546772 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -759,10 +759,11 @@ oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] council_tax_reduction: 838 -# Para 9(2)(d)-(e) exclude the person, whatever family they are in: someone who -# pays the householder as a boarder or lodger, someone jointly liable for the -# council tax, and the liable person (the household head) are nobody's -# non-dependants. +# Para 9(2)(d)-(e) exclude the person, whatever family they are in: someone +# outside the household head's family who pays the householder as a boarder +# or lodger, and a joint tenant, are nobody's non-dependants. Para 9(3)(a)(i) +# keeps as a non-dependant a close relative who pays; with no relationship +# input, a member of the head's own family who pays board is taken to be one. - name: Merton makes no deduction for an adult son who himself pays as a lodger period: 2026 @@ -842,6 +843,40 @@ merton_council_tax_reduction_non_dep_deductions: [691.60, 0] council_tax_reduction: 1_108.40 +- name: Merton charges the applicant's own son who pays her board, as a close relative + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + rent_paid_as_boarder: 2_600 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30: £13.30 a week on £576.92 (para 9(3)(a)(i) keeps him a + # non-dependant although he pays). + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + merton_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + - name: Merton makes no deduction for an adult son who is himself a joint tenant period: 2026 absolute_error_margin: 0.01 From 1a71c4a699fc3cd0171b8e4d1358f3a61046fbb4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:26:30 -0400 Subject: [PATCH 140/141] Settle #2078's provisional grandmother cases now that in-unit non-dependants are charged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each case said it was provisional until #2017: the 19-year-old parents are the grandmother's non-dependants, but the model charged none in her benefit unit. They passed only because the couple defaulted to tertiary education, so the full-time student exemption applied (maintainer review point 3). The couple are now not in education, and the Newham father works 37.5 hours: - England pensioner (JSA): the payee is exempt, the other pays the lowest amount, 270.40, so the reduction is 489.20, not 759.60. - Newham (UC): one deduction, the father's 22.61 a week on £576.92 (1,175.72); the mother has no earned income of her own, so is exempt. The reduction is 84.28, as the maintainer's probe gave. - Kingston upon Thames (UC): both on UC with no earned income, so both are exempt; the reduction is unchanged at 1,627.32. Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index 405fc1ffc3..d4f30bd840 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -140,10 +140,8 @@ simulated_council_tax_reduction_benunit: [759.60] - name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (SI 2012/2885 reg 9(1); + # they are not in her family), and neither is in education. period: 2026 absolute_error_margin: 0.01 input: @@ -156,10 +154,12 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION baby: age: 0 is_household_head: false @@ -183,7 +183,12 @@ council_tax_reduction_relevant_income_based_benefit: [false] # Neither the couple's JSA nor their Child Benefit is her income. council_tax_reduction_applicable_income: [18_514] - simulated_council_tax_reduction_benunit: [759.60] + # 759.60 before non-dependant deductions. The JSA is payable to one of + # the couple, who is exempt (Sch 1 para 8(8)); the other, not in + # remunerative work, pays the lowest amount, 5.20 a week, the couple's + # one deduction (para 8(3)): 270.40. + council_tax_reduction_non_dep_deductions: [270.40] + simulated_council_tax_reduction_benunit: [489.20] - name: A head who applies alone is a single person, with premiums on her own disability only period: 2026 @@ -323,10 +328,8 @@ merton_council_tax_reduction: [1_800, 0] - name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (para 9(1); they are not + # in her family), and neither is in education. period: 2026 absolute_error_margin: 0.01 input: @@ -338,10 +341,13 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION + weekly_hours: 37.5 employment_income: 30_000 baby: age: 0 @@ -365,9 +371,13 @@ council_tax_reduction_applicable_income: [0] # Single aged 25 or over: 95.55 * 52. council_tax_reduction_applicable_amount: [4_968.60] - # No excess income: 1,800 * 70%. On the Universal Credit route the - # couple's earnings and award would leave nothing. - newham_council_tax_reduction: [1_260] + # No excess income: 1,800 * 70% = 1,260. On the Universal Credit route + # the couple's earnings and award would leave nothing. The couple pay one + # deduction (para 30B(3)): the father's £22.61 a week on their joint + # £576.92, 1,175.72 a year; the mother, on Universal Credit with no + # earned income of her own, is exempt. + newham_council_tax_reduction_non_dep_deductions: [1_175.72] + newham_council_tax_reduction: [84.28] - name: A partner under 18 is not liable, but their income counts in the couple's means test period: 2026 @@ -495,10 +505,10 @@ council_tax_reduction_applicable_amount: [7_807.80] - name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (para 9(1); they are not + # in her family), and neither is in education. Both are on the couple's + # Universal Credit with no earned income, so both are exempt under para + # 30A's Universal Credit exemption, and there is no deduction. period: 2026 absolute_error_margin: 0.01 input: @@ -511,10 +521,12 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION baby: age: 0 is_household_head: false @@ -537,6 +549,7 @@ council_tax_reduction_applicable_amount: [4_968.60] # Tariff income ceil((10,000 - 6,000) / 250) = £16 a week, 832 a year, # since she has no award. 1,800 - (5,000 + 832 - 4,968.60) * 20%. + kingston_upon_thames_council_tax_reduction_non_dep_deductions: [0] kingston_upon_thames_council_tax_reduction: [1_627.32] - name: A blind grandmother head's exemption does not exempt a sharer's claim from a non-dependant deduction in Merton From cf0475ddb1498a2f6fd969e5e63fc80943134105 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 02:28:59 -0400 Subject: [PATCH 141/141] Cite what the Default Scheme defines for being on each award Para 2(3)-(4) define being on income-based JSA and income-related ESA as the award being payable to the person, and para 2(1) a person on Income Support as one in receipt of it; State Pension Credit has no definition, so the model reads it the same way. Comment only. Co-Authored-By: Claude Opus 5.5 --- .../local_authorities/council_tax_reduction/_legacy.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 f96f70755a..87eaba23a1 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 @@ -191,10 +191,12 @@ def normal_gross_income_non_dep_deduction( ) full_time_student = is_full_time_student_non_dep(person, period) # No deduction for a non-dependant "who is on" Income Support, State - # Pension Credit, income-based JSA or income-related ESA (para 30(8)(a)): - # an award payable to them (Default Scheme Sch para 2(3)-(4)). Of a couple, - # only the payee is on the couple's award, and another member of their - # benefit unit is on it only through an award of their own. + # Pension Credit, income-based JSA or income-related ESA (para 30(8)(a)). + # A person is on income-based JSA or income-related ESA when it is payable + # to them (Default Scheme Sch para 2(3)-(4)) and on Income Support when in + # receipt of it (para 2(1)); the model reads State Pension Credit the same + # way. Of a couple, only the payee is on the couple's award, and another + # member of their benefit unit is on it only through an award of their own. income_based_benefit = ( person("is_on_income_support", period) | person("is_on_income_based_jsa", period)