From 1d1ed5b2b56e81ecf792e01e9388d1d4cc5f7922 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:28:19 -0400 Subject: [PATCH 001/224] Split pension-age Housing Benefit allowances by when State Pension age was attained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 1 April 2021, SI 2006/214 Sch 3 para 1 (as amended by SI 2021/188; NI: SR 2006/406 Sch 4 para 1) gives the lower pension-age personal allowance to a single claimant or lone parent who attained pensionable age on or after that date, and to a couple both of whom did: £238.00 and £363.25 a week in 2026-27, against £256.00 and £383.35 for earlier cohorts. PolicyEngine applied the higher rate to everyone. - state_pension_age_date: the day each person attains State Pension age, as a YYYYMMDD number, from the Pensions Act 1995 timetable. state_pension_age now derives from it (same results; the timetable logic lives in one place). - housing_benefit_attained_pension_age_before_cutoff and the cutoff and lower-rate parameters (2021-22 to 2026-27 from SI 2021/188 and the Up-rating Orders 2022-2026). - England's pensioner Council Tax Reduction has the same split (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29); Scotland and Wales keep one rate. - YAML boundary tests, a differential test of the attainment day against the statute for every birth date, and property tests of the cohort rule. Co-Authored-By: Claude Opus 5.5 --- .../hb-pension-age-allowance-cohorts.fixed.md | 1 + .../programs/gov/dwp/housing-benefit.ipynb | 16 +- .../allowances/couple/aged_from_cutoff.yaml | 30 ++ .../lone_parent/aged_from_cutoff.yaml | 30 ++ .../allowances/pension_age_cutoff.yaml | 13 + .../allowances/single/aged_from_cutoff.yaml | 30 ++ ...enefit_pension_age_personal_allowance.yaml | 280 ++++++++++++++++++ .../gov/dwp/state_pension_age_date.yaml | 104 +++++++ ...uction_pension_age_personal_allowance.yaml | 133 +++++++++ ...est_housing_benefit_pension_age_cohorts.py | 187 ++++++++++++ .../tests/test_state_pension_age.py | 12 + .../housing_benefit_applicable_amount.py | 25 +- ...efit_attained_pension_age_before_cutoff.py | 39 +++ .../variables/gov/dwp/state_pension_age.py | 30 +- .../gov/dwp/state_pension_age_date.py | 51 ++++ ...council_tax_reduction_applicable_amount.py | 30 +- 16 files changed, 972 insertions(+), 39 deletions(-) create mode 100644 changelog.d/hb-pension-age-allowance-cohorts.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml create mode 100644 policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py create mode 100644 policyengine_uk/variables/gov/dwp/state_pension_age_date.py diff --git a/changelog.d/hb-pension-age-allowance-cohorts.fixed.md b/changelog.d/hb-pension-age-allowance-cohorts.fixed.md new file mode 100644 index 0000000000..d74617015d --- /dev/null +++ b/changelog.d/hb-pension-age-allowance-cohorts.fixed.md @@ -0,0 +1 @@ +Give pension-age Housing Benefit claimants who attained State Pension age on or after 1 April 2021 (and couples who both did) the lower personal allowance in SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), £238.00 and £363.25 a week in 2026-27, instead of the £256.00 and £383.35 kept by earlier cohorts. England's pensioner Council Tax Reduction applies the same split (SI 2012/2885 Sch 2 para 1); Scotland and Wales keep one rate. Adds `state_pension_age_date`, the day each person attains State Pension age. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 4f928b173d..4c4606ea70 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -53,7 +53,11 @@ "source": [ "## Personal allowances\n", "\n", - "The applicable amount uses age- and household-composition bands. The chart below shows how the weekly allowances have evolved." + "The applicable amount uses age- and household-composition bands. The chart below shows how the weekly allowances have evolved.\n", + "\n", + "Since 1 April 2021 the pension-age allowances depend on when members of the benunit attained State Pension age ([SI 2006/214 Sch. 3 para. 1](https://www.legislation.gov.uk/uksi/2006/214/schedule/3), as amended by [SI 2021/188](https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made); Northern Ireland: [SR 2006/406 Sch. 4 para. 1](https://www.legislation.gov.uk/nisr/2006/406/schedule/4)). A single claimant or lone parent who attained it before 1 April 2021, or a couple in which either member did, keeps the higher allowance (`aged`, £256.00 single and £383.35 couple a week in 2026-27). Where every member attained it on or after that date, the lower allowance applies (`aged_from_cutoff`, £238.00 and £363.25). `housing_benefit_attained_pension_age_before_cutoff` compares each member's `state_pension_age_date`, the day set by their date of birth under the Pensions Act 1995 timetable, with `pension_age_cutoff`. The regulations do not settle a couple in which one member has not yet reached State Pension age and the other reached it after the cutoff; PolicyEngine gives it the lower allowance, following the explanatory note to SI 2021/188.\n", + "\n", + "Council Tax Reduction reuses these allowances. England's pensioner scheme has the same split and amounts ([SI 2012/2885 Sch. 2 para. 1](https://www.legislation.gov.uk/uksi/2012/2885/schedule/2), as amended by [SI 2021/29](https://www.legislation.gov.uk/uksi/2021/29/contents/made)). The Scottish ([SSI 2012/319 Sch. 1 para. 2](https://www.legislation.gov.uk/ssi/2012/319/schedule/1)) and Welsh ([SI 2013/3029 Sch. 2 para. 1](https://www.legislation.gov.uk/wsi/2013/3029/schedule/2)) schemes keep one pension-age rate, the higher one." ] }, { @@ -81,16 +85,18 @@ "params = {\n", " \"Single (younger)\": allowances.single.younger,\n", " \"Single (older)\": allowances.single.older,\n", - " \"Single (aged)\": allowances.single.aged,\n", + " \"Single (pension age)\": allowances.single.aged,\n", + " \"Single (pension age, reached from April 2021)\": allowances.single.aged_from_cutoff,\n", " \"Lone parent (younger)\": allowances.lone_parent.younger,\n", " \"Lone parent (older)\": allowances.lone_parent.older,\n", - " \"Lone parent (aged)\": allowances.lone_parent.aged,\n", + " \"Lone parent (pension age)\": allowances.lone_parent.aged,\n", " \"Couple (younger)\": allowances.couple.younger,\n", " \"Couple (older)\": allowances.couple.older,\n", - " \"Couple (aged)\": allowances.couple.aged,\n", + " \"Couple (pension age)\": allowances.couple.aged,\n", + " \"Couple (pension age, both reached from April 2021)\": allowances.couple.aged_from_cutoff,\n", "}\n", "\n", - "dates = [f\"{y}-04-01\" for y in range(2016, 2026)]\n", + "dates = [f\"{y}-04-01\" for y in range(2016, 2027)]\n", "rows = [\n", " {\"date\": d, \"composition\": name, \"weekly allowance\": p(d)}\n", " for d in dates\n", diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml new file mode 100644 index 0000000000..9a36d00ec6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a couple where both members attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 270.3 + 2022-04-01: 278.7 + 2023-04-01: 306.85 + 2024-04-01: 332.95 + 2025-04-01: 346.6 + 2026-04-01: 363.25 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit couple personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(2)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml new file mode 100644 index 0000000000..17e5e79a24 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a lone parent who attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 177.1 + 2022-04-01: 182.6 + 2023-04-01: 201.05 + 2024-04-01: 218.15 + 2025-04-01: 227.1 + 2026-04-01: 238 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit lone parent personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml new file mode 100644 index 0000000000..990ae7995c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml @@ -0,0 +1,13 @@ +description: Since April 2021, pension-age Housing Benefit personal allowances depend on when members of the benefit unit attained State Pension age. The higher allowances apply if any member attained it before this date, written as a YYYYMMDD number; the lower allowances apply if every member attained it on or after this date. +values: + 2021-04-01: 20210401 +metadata: + unit: date + label: Housing Benefit pension-age personal allowance cutoff + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml new file mode 100644 index 0000000000..5c446ef80a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a single claimant who attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 177.1 + 2022-04-01: 182.6 + 2023-04-01: 201.05 + 2024-04-01: 218.15 + 2025-04-01: 227.1 + 2026-04-01: 238 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit single personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml new file mode 100644 index 0000000000..a6244d0f93 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -0,0 +1,280 @@ +# Pension-age Housing Benefit personal allowances by when State Pension age was +# attained: SI 2006/214 Sch 3 para 1 (NI: SR 2006/406 Sch 4 para 1), as amended +# by SI 2021/188 from 1 April 2021. Higher rates (1)(b)/(2)(b) where a member +# attained pensionable age before 1 April 2021; lower rates (1)(c)/(2)(c) where +# every member attained it on or after. Amounts are the Up-rating Order rates. +# Age and months_since_last_birthday place each date of birth at 6 October. + +- name: Single woman who attained State Pension age on 31 March 2021 keeps the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 256.00 * 52 # para 1(1)(b), claimant attained 31 March 2021 + +- name: Single man who attained State Pension age on 1 April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: true + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c), claimant attained 1 April 2021 + +- name: Single person aged 80 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + months_since_last_birthday: 4.1613 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 256.00 * 52 # para 1(1)(b), claimant attained 1 June 2006 + +- name: Single person aged 70 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c), claimant attained 6 April 2022 + +- name: In 2021-22, a single person who attained State Pension age on 31 March 2021 gets the higher rate + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 191.15 * 52 # para 1(1)(b), claimant attained 31 March 2021 + +- name: In 2021-22, a single person who attained State Pension age on 1 April 2021 gets the lower rate + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 177.10 * 52 # para 1(1)(c), claimant attained 1 April 2021 + +- name: Couple where one member attained State Pension age before April 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 80 + months_since_last_birthday: 4.1613 + is_male: true + younger: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 1 June 2011, younger attained 6 April 2022 + +- name: Couple where both attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 363.25 * 52 # para 1(2)(c), older attained 6 April 2022, younger attained 1 January 2024 + +- name: Couple on either side of the cutoff by one day gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 71 + months_since_last_birthday: 6.19355 + is_male: true + younger: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 31 March 2021, younger attained 1 April 2021 + +- name: Mixed-age couple whose older member attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 363.25 * 52 # para 1(2)(c), older attained 6 April 2022, younger attained 1 January 2033 + +- name: Mixed-age couple whose older member attained State Pension age before April 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 80 + months_since_last_birthday: 4.1613 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 1 June 2011, younger attained 1 January 2033 + +- name: Pension-age lone parent who attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + grandchild: + age: 12 + benunits: + benunit: + members: [claimant, grandchild] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + is_lone_parent: true + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c) + +- name: Before April 2021 every pension-age unit counts as attaining State Pension age before the cutoff + period: 2020 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 0.0 + is_male: false + benunits: + benunit: + members: [claimant] + output: + housing_benefit_attained_pension_age_before_cutoff: true + +- name: Private renter aged 70 with a £30,000 State Pension is assessed at the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 30_000 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 + housing_benefit: 47.68 # 656.08 at the higher rate: 0.65 x (256 - 238) x 52 = 608.40 more diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml new file mode 100644 index 0000000000..f9c1f230b3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml @@ -0,0 +1,104 @@ +# The day State Pension age is attained, written as a YYYYMMDD number, at +# boundaries of Pensions Act 1995 Schedule 4 paragraph 1. Age and +# months_since_last_birthday place the date of birth named in each test at +# 6 October of the fiscal year. Expected days come from a day-level reading +# of the statute. + +- name: Woman born 5 April 1950 attains State Pension age on her 60th birthday + period: 2015 + input: + age: 65 + months_since_last_birthday: 6.0323 + is_male: false + output: + state_pension_age_date: 20100405 + +- name: Woman born 6 April 1950 attains it on 6 May 2010 (table 1) + period: 2015 + input: + age: 65 + months_since_last_birthday: 6.0 + is_male: false + output: + state_pension_age_date: 20100506 + +- name: Man born 5 December 1953 attains it on his 65th birthday + period: 2018 + input: + age: 64 + months_since_last_birthday: 10.03333 + is_male: true + output: + state_pension_age_date: 20181205 + +- name: Man born 6 December 1953 attains it on 6 March 2019 (table 2) + period: 2018 + input: + age: 64 + months_since_last_birthday: 10.0 + is_male: true + output: + state_pension_age_date: 20190306 + +- name: Man born 5 October 1954 attains it on 6 September 2020 (table 2) + period: 2020 + input: + age: 66 + months_since_last_birthday: 0.03333 + is_male: true + output: + state_pension_age_date: 20200906 + +- name: Woman born 6 October 1954 attains it on her 66th birthday + period: 2020 + input: + age: 66 + months_since_last_birthday: 0.0 + is_male: false + output: + state_pension_age_date: 20201006 + +- name: Woman born 31 March 1955 attains it on 31 March 2021, before the Housing Benefit cutoff + period: 2026 + input: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + output: + state_pension_age_date: 20210331 + +- name: Man born 1 April 1955 attains it on 1 April 2021, the Housing Benefit cutoff + period: 2026 + input: + age: 71 + months_since_last_birthday: 6.1613 + is_male: true + output: + state_pension_age_date: 20210401 + +- name: Person born 31 July 1960 attains it on 30 November 2026 (rule 7A) + period: 2026 + input: + age: 66 + months_since_last_birthday: 2.19355 + is_male: false + output: + state_pension_age_date: 20261130 + +- name: Person born 6 March 1961 attains it on their 67th birthday + period: 2028 + input: + age: 67 + months_since_last_birthday: 7.0 + is_male: true + output: + state_pension_age_date: 20280306 + +- name: Person born 6 April 1978 attains it on their 68th birthday + period: 2046 + input: + age: 68 + months_since_last_birthday: 6.0 + is_male: false + output: + state_pension_age_date: 20460406 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml new file mode 100644 index 0000000000..57718e6eca --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -0,0 +1,133 @@ +# Pension-age Council Tax Reduction personal allowances by country. England's +# prescribed requirements (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29) give +# the lower rates where every member attained State Pension age on or after +# 1 April 2021; the Scottish (SSI 2012/319 Sch 1 para 2) and Welsh (SI 2013/3029 +# Sch 2 para 1) schemes have one pension-age rate. Amounts are for 2026-27. + +- name: In England, a single person who attained State Pension age on 31 March 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: LONDON + output: + council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In England, a single person who attained State Pension age on 1 April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: NORTH_EAST + output: + council_tax_reduction_applicable_amount: 238.00 * 52 + +- name: In England, a couple who both attained State Pension age after April 2021 get the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: SOUTH_WEST + output: + council_tax_reduction_applicable_amount: 363.25 * 52 + +- name: In Scotland, a single person who attained State Pension age on 1 April 2021 keeps the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In Scotland, a couple who both attained State Pension age after April 2021 keep the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 383.35 * 52 + +- name: In Wales, a single person who attained State Pension age on 1 April 2021 keeps the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: WALES + output: + council_tax_reduction_applicable_amount: 256.00 * 52 diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py new file mode 100644 index 0000000000..368c3bf3b5 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -0,0 +1,187 @@ +"""Pension-age Housing Benefit personal allowances by when State Pension age +was attained. + +The Housing Benefit (Persons who have attained the qualifying age for state +pension credit) Regulations 2006 (SI 2006/214) Sch 3 para 1, as amended by SI +2021/188 from 1 April 2021 (Northern Ireland: SR 2006/406 Sch 4 para 1): a +single claimant or lone parent who attained pensionable age before 1 April +2021 gets the higher allowance in (1)(b), one who attained it on or after gets +(1)(c); a couple gets (2)(b) where one or both members attained it before, and +(2)(c) where both attained it on or after. + +Invariants, for any pension-age single person or couple in any year: + +1. Differential: the pension-age personal allowance in + housing_benefit_applicable_amount is the higher rate exactly when a member's + attainment day, read from the statute day by day + (test_state_pension_age.reference_attainment_day), is before 1 April 2021, + and the lower rate otherwise. +2. Bounds: the lower rate is below the higher rate in every year, including + uprated years, and lone parents get the single rates. +3. Monotonicity: among pension-age single people of one sex in one year, an + earlier date of birth never gives a lower allowance. +4. Before April 2021 there was one pension-age rate, which every pension-age + unit gets. +5. Council Tax Reduction: England's pensioner scheme has the same split and + amounts (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29), so its + allowance equals Housing Benefit's; the Scottish and Welsh schemes have + one pension-age rate, the higher one. +""" + +from datetime import date, timedelta + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from test_state_pension_age import grid_months, reference_attainment_day + +CUTOFF = date(2021, 4, 1) +YEARS = list(range(2019, 2031)) +P = system.parameters.gov.dwp.housing_benefit.allowances + + +def rates(year: int, couple: bool) -> tuple: + """(higher, lower) weekly pension-age allowance; lower is None before the + cutoff took effect.""" + node = P.couple if couple else P.single + at = f"{year}-06-01" + return node.aged(at), node.aged_from_cutoff(at) + + +def build(units: list, year: int, region: str = "LONDON") -> Simulation: + """Each unit is a list of (birth, male). Units are eligible with no + premiums, so the applicable amount is 52 times the personal allowance.""" + people, benunits = {}, {} + mid_year = grid_months(date(year, 10, 6)) + for u, members in enumerate(units): + names = [] + for k, (birth, male) in enumerate(members): + age_in_months = mid_year - grid_months(birth) + age = int(age_in_months // 12) + name = f"u{u}p{k}" + people[name] = { + "age": {year: age}, + "months_since_last_birthday": {year: age_in_months - 12 * age}, + "is_male": {year: male}, + } + names.append(name) + benunits[f"u{u}"] = { + "members": names, + "housing_benefit_eligible": {year: True}, + "benefits_premiums": {year: 0}, + } + return Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"h": {"members": list(people), "region": {year: region}}}, + } + ) + + +def at_pension_age(members: list, year: int) -> bool: + mid_year = date(year, 10, 6) + return any(reference_attainment_day(b, m) <= mid_year for b, m in members) + + +def expected_allowance(members: list, year: int) -> float: + higher, lower = rates(year, couple=len(members) == 2) + attained = [reference_attainment_day(b, m) for b, m in members] + if lower is None or any(day < CUTOFF for day in attained): + return higher + return lower + + +def check(units: list, year: int) -> np.ndarray: + """Invariant 1 for every unit with a member over State Pension age.""" + amount = build(units, year).calculate("housing_benefit_applicable_amount", year) + weekly = amount / 52 + wrong = [ + (members, weekly[i], expected_allowance(members, year)) + for i, members in enumerate(units) + if at_pension_age(members, year) + and abs(weekly[i] - expected_allowance(members, year)) > 0.005 + ] + assert not wrong, wrong[:10] + return weekly + + +BIRTHS = [date(1938, 1, 1) + timedelta(days=d) for d in range(0, 23 * 366, 5)] +EDGES = [date(1955, 4, 1) + timedelta(days=shift) for shift in range(-3, 4)] + + +@pytest.mark.parametrize("year", YEARS) +def test_single_allowance_matches_statute_for_every_birth_date(year): + """Invariants 1, 3 and 4 for single people born every fifth day from 1938 + and either side of 1 April 1955, the birthday that reaches 66 on the + cutoff.""" + births = sorted(set(BIRTHS) | set(EDGES)) + for male in (True, False): + units = [[(birth, male)] for birth in births] + weekly = check(units, year) + eligible = [weekly[i] for i, u in enumerate(units) if at_pension_age(u, year)] + assert all(a >= b - 1e-6 for a, b in zip(eligible, eligible[1:])) + if year < CUTOFF.year: + assert np.allclose(eligible, rates(year, couple=False)[0]) + + +@pytest.mark.parametrize("year", range(2021, 2041)) +def test_lower_rates_are_below_higher_rates(year): + """Invariant 2, including years uprated past the last published rate.""" + for couple in (False, True): + higher, lower = rates(year, couple) + assert lower < higher + at = f"{year}-06-01" + assert P.lone_parent.aged_from_cutoff(at) == P.single.aged_from_cutoff(at) + + +MEMBER = st.tuples( + st.dates(min_value=date(1930, 1, 1), max_value=date(1962, 12, 31)), + st.booleans(), +) + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=25), + year=st.sampled_from(YEARS), +) +def test_any_single_or_couple_follows_the_cohort_rule(units, year): + """Invariant 1 for random single people and couples, including couples + where only one member is over State Pension age.""" + check(units, year) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=20), + year=st.sampled_from(YEARS), + region=st.sampled_from(["LONDON", "NORTH_WEST", "SCOTLAND", "WALES"]), +) +def test_council_tax_reduction_splits_only_in_england(units, year, region): + """Invariant 5 for random single people and couples.""" + sim = build(units, year, region) + hb = sim.calculate("housing_benefit_applicable_amount", year) / 52 + ctr = sim.calculate("council_tax_reduction_applicable_amount", year) / 52 + for i, members in enumerate(units): + if not at_pension_age(members, year): + continue + if region in ("SCOTLAND", "WALES"): + expected = rates(year, couple=len(members) == 2)[0] + else: + expected = hb[i] + assert abs(ctr[i] - expected) < 0.005, (members, region, ctr[i], expected) diff --git a/policyengine_uk/tests/test_state_pension_age.py b/policyengine_uk/tests/test_state_pension_age.py index 12e8eced7d..aab0aaed42 100644 --- a/policyengine_uk/tests/test_state_pension_age.py +++ b/policyengine_uk/tests/test_state_pension_age.py @@ -263,6 +263,18 @@ def test_status_matches_statute_for_every_birth_date(year): assert not mismatches, mismatches[:10] +@pytest.mark.parametrize("year", YEARS) +def test_attainment_date_matches_statute_for_every_birth_date(year): + """state_pension_age_date is the day a day-level reading of the statute + gives, for every birth date in the differential set, and the same day + whichever year the person is placed from.""" + sim, keys = differential_simulation() + model = sim.calculate("state_pension_age_date", year) + expected = np.array([ymd(reference_attainment_day(b, m)) for b, m in keys]) + wrong = np.flatnonzero(model != expected) + assert not len(wrong), [(keys[i], model[i], expected[i]) for i in wrong[:10]] + + def mid_year_share(year: int, age: int, male: bool) -> float: """Share of people aged `age` on 6 October who have attained pensionable age by then, with birthdays spread evenly over the year.""" diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py index 50c086675b..af23be37f1 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py @@ -22,20 +22,39 @@ def formula(benunit, period, parameters): single = benunit("is_single_person", period) couple = benunit("is_couple", period) lone_parent = benunit("is_lone_parent", period) + # Pension-age allowances (SI 2006/214 Sch 3 para 1; NI: SR 2006/406 + # Sch 4 para 1). Since 1 April 2021 units whose members all attained + # State Pension age on or after the cutoff get the lower rates in + # sub-paragraphs (1)(c) and (2)(c); the rest keep the higher rates. + if p.pension_age_cutoff is None: + single_aged, couple_aged, lone_parent_aged = ( + p.single.aged, + p.couple.aged, + p.lone_parent.aged, + ) + else: + before_cutoff = benunit( + "housing_benefit_attained_pension_age_before_cutoff", period + ) + single_aged = where(before_cutoff, p.single.aged, p.single.aged_from_cutoff) + couple_aged = where(before_cutoff, p.couple.aged, p.couple.aged_from_cutoff) + lone_parent_aged = where( + before_cutoff, p.lone_parent.aged, p.lone_parent.aged_from_cutoff + ) single_personal_allowance = ( u_25 * p.single.younger + o_25 * p.single.older - + any_over_SP_age * p.single.aged + + any_over_SP_age * single_aged ) couple_personal_allowance = ( u_18 * p.couple.younger + o_18 * p.couple.older - + any_over_SP_age * p.couple.aged + + any_over_SP_age * couple_aged ) lone_parent_personal_allowance = ( u_18 * p.lone_parent.younger + o_18 * p.lone_parent.older - + any_over_SP_age * p.lone_parent.aged + + any_over_SP_age * lone_parent_aged ) personal_allowance = ( single * single_personal_allowance diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py new file mode 100644 index 0000000000..20aa6699ca --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_attained_pension_age_before_cutoff(Variable): + value_type = bool + entity = BenUnit + label = "a member attained State Pension age before the Housing Benefit cutoff" + documentation = ( + "Whether any member of the benefit unit attained State Pension age " + "before the pension-age cutoff (1 April 2021). Such units keep the " + "higher pension-age personal allowance; units whose members all " + "attained it on or after the cutoff get the lower one. Before the " + "cutoff took effect there was a single pension-age allowance, the " + "higher one, so this is true for every unit then." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", + "https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/4", + ) + + def formula(benunit, period, parameters): + cutoff = parameters( + period + ).gov.dwp.housing_benefit.allowances.pension_age_cutoff + if cutoff is None: + return np.ones(benunit.count, dtype=bool) + # SI 2006/214 Sch 3 para 1(2)(b) keeps the higher couple rate where + # "one member or both members" attained pensionable age before the + # cutoff; (2)(c) gives the lower one only where both attained it on or + # after. The regulations do not say which applies to a couple where one + # member has not yet attained it and the other did so after the cutoff + # (few such units remain on pension-age Housing Benefit). The + # explanatory note to SI 2021/188 keeps the uplift only where "one or + # more members ... attained pensionable age before 1st April 2021", so + # such couples get the lower rate here. + attained = benunit.members("state_pension_age_date", period) + return benunit.any(attained < cutoff) diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age.py b/policyengine_uk/variables/gov/dwp/state_pension_age.py index f93a15799e..9296e4a9af 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_age.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_age.py @@ -1,9 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.dates import ( - add_months_to_yyyymmdd, exact_age_in_months, grid_month, - grid_months_to_yyyymmdd, yyyymmdd_to_grid_months, ) @@ -26,35 +24,11 @@ class state_pension_age(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.dwp.state_pension.age # Dates are in months on a grid whose months start on the 6th, so the # middle of the fiscal year (6 October) is a whole month. - mid_year = grid_month(period.start.year, 10) age_in_months = exact_age_in_months( person("age", period), person("months_since_last_birthday", period) ) - birth = mid_year - age_in_months - # The day of birth: the one starting at or after that instant, so the - # person's legal age on 6 October is age. - birth_date = grid_months_to_yyyymmdd(birth) - - # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December - # 1953 attain pensionable age at 65. Everyone else follows the - # timetable in rules (2) to (10), which sets either an age or a day. - male_rule = person("is_male", period) & (birth_date < p.male.born_before) - age_rule = where(male_rule, p.male.age, p.age_by_birth_date.calc(birth_date)) - day_rule = where(male_rule, 0, p.day_by_birth_date.calc(birth_date)) - day_rule_month = where( - day_rule > 0, - yyyymmdd_to_grid_months(np.maximum(day_rule, 10101)), - -np.inf, - ) - # An age of N years and M months is attained at the commencement of - # the anniversary (Family Law Reform Act 1969 s.9(1)): the same day of - # the month, or the month's last day where that day does not exist, - # which also gives the three days in rule (7A). - anniversary = yyyymmdd_to_grid_months( - add_months_to_yyyymmdd(birth_date, age_rule) - ) - attained = np.maximum(anniversary, day_rule_month) + birth = grid_month(period.start.year, 10) - age_in_months + attained = yyyymmdd_to_grid_months(person("state_pension_age_date", period)) return (attained - birth) / 12 diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_date.py b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py new file mode 100644 index 0000000000..1fbe6532ad --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import ( + add_months_to_yyyymmdd, + exact_age_in_months, + grid_month, + grid_months_to_yyyymmdd, +) + + +class state_pension_age_date(Variable): + value_type = int + entity = Person + label = "date this person attains State Pension age" + documentation = ( + "The day on which this person attains State Pension age (pensionable " + "age), written as a YYYYMMDD number. It is set by their date of birth, " + "which comes from age and months_since_last_birthday." + ) + definition_period = YEAR + unit = "date" + reference = ( + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4", + "https://www.legislation.gov.uk/ukpga/2014/19/section/26", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.state_pension.age + # Dates are in months on a grid whose months start on the 6th, so the + # middle of the fiscal year (6 October) is a whole month. + age_in_months = exact_age_in_months( + person("age", period), person("months_since_last_birthday", period) + ) + # The day of birth: the one starting at or after that instant, so the + # person's legal age on 6 October is age. + birth_date = grid_months_to_yyyymmdd( + grid_month(period.start.year, 10) - age_in_months + ) + + # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December + # 1953 attain pensionable age at 65. Everyone else follows the + # timetable in rules (2) to (10), which sets either an age or a day. + male_rule = person("is_male", period) & (birth_date < p.male.born_before) + age_rule = where(male_rule, p.male.age, p.age_by_birth_date.calc(birth_date)) + day_rule = where(male_rule, 0, p.day_by_birth_date.calc(birth_date)) + # An age of N years and M months is attained at the commencement of + # the anniversary (Family Law Reform Act 1969 s.9(1)): the same day of + # the month, or the month's last day where that day does not exist, + # which also gives the three days in rule (7A). Where the statute sets + # a day instead, the day rule is later; otherwise it is zero. + anniversary = add_months_to_yyyymmdd(birth_date, age_rule) + return np.maximum(anniversary, day_rule) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index c096429589..f97b91c997 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_applicable_amount(Variable): @@ -21,20 +22,43 @@ def formula(benunit, period, parameters): single = benunit("is_single_person", period) couple = benunit("is_couple", period) lone_parent = benunit("is_lone_parent", period) + # Pension-age personal allowances. England's prescribed requirements + # (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29 from 2021-22) split + # them by when State Pension age was attained, with the same cutoff and + # amounts as Housing Benefit. The Scottish (SSI 2012/319 Sch 1 para 2) + # and Welsh (SI 2013/3029 Sch 2 para 1) schemes keep one pension-age + # rate, the higher one. + single_aged, couple_aged, lone_parent_aged = ( + p.single.aged, + p.couple.aged, + p.lone_parent.aged, + ) + if p.pension_age_cutoff is not None: + in_england = benunit.any( + benunit.members.household("country", period) == Country.ENGLAND + ) + lower = in_england & ~benunit( + "housing_benefit_attained_pension_age_before_cutoff", period + ) + single_aged = where(lower, p.single.aged_from_cutoff, single_aged) + couple_aged = where(lower, p.couple.aged_from_cutoff, couple_aged) + lone_parent_aged = where( + lower, p.lone_parent.aged_from_cutoff, lone_parent_aged + ) single_personal_allowance = ( u_25 * p.single.younger + o_25 * p.single.older - + any_over_SP_age * p.single.aged + + any_over_SP_age * single_aged ) couple_personal_allowance = ( u_18 * p.couple.younger + o_18 * p.couple.older - + any_over_SP_age * p.couple.aged + + any_over_SP_age * couple_aged ) lone_parent_personal_allowance = ( u_18 * p.lone_parent.younger + o_18 * p.lone_parent.older - + any_over_SP_age * p.lone_parent.aged + + any_over_SP_age * lone_parent_aged ) personal_allowance = ( single * single_personal_allowance From f159ff49c89559a54a135ebf293bf57bfa780710 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:41:25 -0400 Subject: [PATCH 002/224] Address review: mixed-age couples, pensionable age, statute table test - Explain why the mixed-age couple gap cannot arise for pension-age Housing Benefit in law, and why PolicyEngine gives such units the lower rate; add the same note for England's CTR rows (2) and (5). - Cite where "pensionable age" is defined (SSCBA 1992 s.122(1), applied by the Interpretation Act 1978 s.11) and why dates are compared as integers. - CTR reads the country from the benefit unit's household, as the CTR award formula does. - Test the lower rates and cutoff against the statute's table for 2021-22 to 2026-27, and England's CTR rate for a mixed-age couple. Co-Authored-By: Claude Opus 5.5 --- ...enefit_pension_age_personal_allowance.yaml | 3 +- ...uction_pension_age_personal_allowance.yaml | 24 ++++++++++++++ ...est_housing_benefit_pension_age_cohorts.py | 32 +++++++++++++++++-- ...efit_attained_pension_age_before_cutoff.py | 28 ++++++++++------ ...council_tax_reduction_applicable_amount.py | 17 +++++----- 5 files changed, 84 insertions(+), 20 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml index a6244d0f93..3a3cacfff9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -3,7 +3,8 @@ # by SI 2021/188 from 1 April 2021. Higher rates (1)(b)/(2)(b) where a member # attained pensionable age before 1 April 2021; lower rates (1)(c)/(2)(c) where # every member attained it on or after. Amounts are the Up-rating Order rates. -# Age and months_since_last_birthday place each date of birth at 6 October. +# Each date of birth is placed by age and months_since_last_birthday, both +# measured at 6 October of the fiscal year. - name: Single woman who attained State Pension age on 31 March 2021 keeps the higher rate period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml index 57718e6eca..ae22978388 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -131,3 +131,27 @@ region: WALES output: council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In England, a mixed-age couple whose older member attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: EAST_MIDLANDS + output: + council_tax_reduction_applicable_amount: 363.25 * 52 diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 368c3bf3b5..93979e538c 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -22,7 +22,9 @@ earlier date of birth never gives a lower allowance. 4. Before April 2021 there was one pension-age rate, which every pension-age unit gets. -5. Council Tax Reduction: England's pensioner scheme has the same split and +5. The lower rates and the cutoff equal the statute's table for every year + 2021-22 to 2026-27 (hard-coded below from legislation.gov.uk). +6. Council Tax Reduction: England's pensioner scheme has the same split and amounts (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29), so its allowance equals Housing Benefit's; the Scottish and Welsh schemes have one pension-age rate, the higher one. @@ -173,7 +175,7 @@ def test_any_single_or_couple_follows_the_cohort_rule(units, year): region=st.sampled_from(["LONDON", "NORTH_WEST", "SCOTLAND", "WALES"]), ) def test_council_tax_reduction_splits_only_in_england(units, year, region): - """Invariant 5 for random single people and couples.""" + """Invariant 6 for random single people and couples.""" sim = build(units, year, region) hb = sim.calculate("housing_benefit_applicable_amount", year) / 52 ctr = sim.calculate("council_tax_reduction_applicable_amount", year) / 52 @@ -185,3 +187,29 @@ def test_council_tax_reduction_splits_only_in_england(units, year, region): else: expected = hb[i] assert abs(ctr[i] - expected) < 0.005, (members, region, ctr[i], expected) + + +# SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), weekly: inserted by SI 2021/188 reg +# 2(3) from 1 April 2021, then set by the Up-rating Orders SI 2022/292, +# 2023/316, 2024/242, 2025/295 and 2026/148 (legislation.gov.uk point-in-time +# texts at 11 April 2022, 10 April 2023, 8 April 2024, 7 April 2025 and the +# current text). +STATUTE_LOWER_RATES = { + 2021: (177.10, 270.30), + 2022: (182.60, 278.70), + 2023: (201.05, 306.85), + 2024: (218.15, 332.95), + 2025: (227.10, 346.60), + 2026: (238.00, 363.25), +} + + +@pytest.mark.parametrize("year", sorted(STATUTE_LOWER_RATES)) +def test_lower_rates_and_cutoff_match_the_statute(year): + """Invariant 5.""" + single, couple = STATUTE_LOWER_RATES[year] + at = f"{year}-06-01" + assert P.single.aged_from_cutoff(at) == pytest.approx(single, abs=1e-6) + assert P.lone_parent.aged_from_cutoff(at) == pytest.approx(single, abs=1e-6) + assert P.couple.aged_from_cutoff(at) == pytest.approx(couple, abs=1e-6) + assert P.pension_age_cutoff(at) == 20210401 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py index 20aa6699ca..6869eac8b3 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py @@ -26,14 +26,24 @@ def formula(benunit, period, parameters): ).gov.dwp.housing_benefit.allowances.pension_age_cutoff if cutoff is None: return np.ones(benunit.count, dtype=bool) - # SI 2006/214 Sch 3 para 1(2)(b) keeps the higher couple rate where - # "one member or both members" attained pensionable age before the - # cutoff; (2)(c) gives the lower one only where both attained it on or - # after. The regulations do not say which applies to a couple where one - # member has not yet attained it and the other did so after the cutoff - # (few such units remain on pension-age Housing Benefit). The - # explanatory note to SI 2021/188 keeps the uplift only where "one or - # more members ... attained pensionable age before 1st April 2021", so - # such couples get the lower rate here. + # "Pensionable age" is State Pension age under Pensions Act 1995 Sch 4 + # para 1 (SSCBA 1992 s.122(1), applied to these regulations by the + # Interpretation Act 1978 s.11), which state_pension_age_date follows. + # + # Para 1(2)(b) keeps the higher couple rate where "one member or both + # members" attained pensionable age before the cutoff, and (2)(c) gives + # the lower one where "both members" attained it on or after. Neither + # covers a couple where one member has not reached it and the other did + # so after the cutoff. In law that couple cannot be on pension-age + # Housing Benefit: mixed-age couples keep it only under the savings for + # awards from before 15 May 2019 (SI 2019/37 art 4), when the older + # member had already reached it. PolicyEngine can still give such a + # unit pension-age rates through a reported continuing award; it gets + # the lower rate, since the explanatory note to SI 2021/188 keeps the + # uplift only where "one or more members ... attained pensionable age + # before 1st April 2021". + # + # Dates are compared as YYYYMMDD integers, which are exact; as float32 + # they would not be (20210401 would round to 20210400). attained = benunit.members("state_pension_age_date", period) return benunit.any(attained < cutoff) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index f97b91c997..cd47276884 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -23,20 +23,21 @@ def formula(benunit, period, parameters): couple = benunit("is_couple", period) lone_parent = benunit("is_lone_parent", period) # Pension-age personal allowances. England's prescribed requirements - # (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29 from 2021-22) split - # them by when State Pension age was attained, with the same cutoff and - # amounts as Housing Benefit. The Scottish (SSI 2012/319 Sch 1 para 2) - # and Welsh (SI 2013/3029 Sch 2 para 1) schemes keep one pension-age - # rate, the higher one. + # (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29 for schemes for + # financial years from 2021-22) split them by when State Pension age + # was attained, with the same cutoff and amounts as Housing Benefit. + # The Scottish (SSI 2012/319 Sch 1 para 2) and Welsh (SI 2013/3029 + # Sch 2 para 1) schemes keep one pension-age rate, the higher one. + # England's rows (2) and (5) do not cover a couple where one member has + # not reached State Pension age and the other did so after the cutoff; + # as for Housing Benefit, such a couple gets the lower rate. single_aged, couple_aged, lone_parent_aged = ( p.single.aged, p.couple.aged, p.lone_parent.aged, ) if p.pension_age_cutoff is not None: - in_england = benunit.any( - benunit.members.household("country", period) == Country.ENGLAND - ) + in_england = benunit.household("country", period) == Country.ENGLAND lower = in_england & ~benunit( "housing_benefit_attained_pension_age_before_cutoff", period ) From 54f4c7da480f79963967dc6689af022e82673636 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:19:41 -0400 Subject: [PATCH 003/224] Add share-like wealth component inputs corporate_wealth bundles three assets the means tests value differently: quoted shares less 10% for the expenses of sale (ADM H1665), unit trusts at the manager's withdrawal price with no deduction (H1673-H1674), and stocks and shares ISAs at their withdrawal value (H1656). Add household inputs for the two parts not yet modelled, directly_held_shares (UK shares and employee shares and options) and unit_and_investment_trusts, and uprate them, cash_isa and stocks_and_shares_isa with per-capita GDP like corporate_wealth, so a dataset's identity corporate_wealth == sum of components survives projection. The inputs have no consumers yet. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 1 + docs/book/assumptions/growthfactors.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 4 ++ .../tests/test_wealth_components.py | 46 +++++++++++++++++++ .../variables/input/corporate_wealth.py | 10 ++-- .../variables/input/directly_held_shares.py | 22 +++++++++ .../input/unit_and_investment_trusts.py | 21 +++++++++ 7 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 changelog.d/wealth-share-components.added.md create mode 100644 policyengine_uk/tests/test_wealth_components.py create mode 100644 policyengine_uk/variables/input/directly_held_shares.py create mode 100644 policyengine_uk/variables/input/unit_and_investment_trusts.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..78001b6ac6 --- /dev/null +++ b/changelog.d/wealth-share-components.added.md @@ -0,0 +1 @@ +Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 1b093f7b61..3bab5902dd 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 4fbc8bc819..5e962bb401 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -83,7 +83,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains_badr - capital_gains_residential_property - capital_gains_carried_interest +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - lump_sum_income @@ -102,7 +104,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income +- unit_and_investment_trusts gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income gov.economic_assumptions.yoy_growth.obr.private_pension_index: diff --git a/policyengine_uk/tests/test_wealth_components.py b/policyengine_uk/tests/test_wealth_components.py new file mode 100644 index 0000000000..98e4799477 --- /dev/null +++ b/policyengine_uk/tests/test_wealth_components.py @@ -0,0 +1,46 @@ +"""corporate_wealth's components share its entity, quantity type and uprating, so +a dataset's identity corporate_wealth == sum of components survives projection.""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) + +system = CountryTaxBenefitSystem() +UPRATING_INDICES = yaml.safe_load( + (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() +) + + +def _uprating_index(variable): + return [index for index, names in UPRATING_INDICES.items() if variable in names] + + +def test_components_match_corporate_wealth(): + corporate_wealth = system.variables["corporate_wealth"] + for name in COMPONENTS: + variable = system.variables[name] + assert variable.is_input_variable() + assert variable.entity.key == corporate_wealth.entity.key + assert variable.quantity_type == corporate_wealth.quantity_type + assert variable.uprating == corporate_wealth.uprating + assert _uprating_index(name) == _uprating_index("corporate_wealth") + + +def test_cash_isa_is_uprated_with_savings(): + assert system.variables["cash_isa"].uprating == system.variables["savings"].uprating + assert _uprating_index("cash_isa") == _uprating_index("savings") + + +def test_components_are_not_part_of_total_wealth(): + """total_wealth adds corporate_wealth, which already holds the components.""" + adds = system.variables["total_wealth"].adds + assert "corporate_wealth" in adds + assert not set(COMPONENTS) & set(adds) diff --git a/policyengine_uk/variables/input/corporate_wealth.py b/policyengine_uk/variables/input/corporate_wealth.py index e1dabe6b62..c86bda946e 100644 --- a/policyengine_uk/variables/input/corporate_wealth.py +++ b/policyengine_uk/variables/input/corporate_wealth.py @@ -4,11 +4,11 @@ class corporate_wealth(Variable): label = "corporate wealth" documentation = ( - "Wealth held in corporations directly or through investment funds: UK " - "shares, employee shares and options, unit and investment trusts, and " - "stocks and shares ISAs, imputed from the Wealth and Assets Survey. The " - "stocks and shares ISA component is also exported on its own as " - "stocks_and_shares_isa, so the two must never be summed. Private pension " + "Wealth held in corporations directly or through investment funds, " + "outside pensions: directly_held_shares (UK shares and employee shares " + "and options), unit_and_investment_trusts and stocks_and_shares_isa. " + "Datasets that carry those components build this variable as their " + "exact sum, so it must never be summed with any of them. Private pension " "wealth is carried separately in private_pension_wealth; datasets built " "before that split folded it into this variable." ) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py new file mode 100644 index 0000000000..71f82217f4 --- /dev/null +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class directly_held_shares(Variable): + label = "directly held shares" + documentation = ( + "Value of shares the household holds directly, outside ISAs and pooled " + "funds: shares in UK companies, listed or not, and employee shares and " + "share options. Part of corporate_wealth, so the two must never be " + "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " + "(UK shares plus employee shares and options); overseas shares are not " + "yet imputed. Quoted shares are valued for the means tests at their " + "price less 10% for the expenses of sale (Advice for Decision Making " + "H1665)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py new file mode 100644 index 0000000000..07e59aaee1 --- /dev/null +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class unit_and_investment_trusts(Variable): + label = "unit and investment trusts" + documentation = ( + "Value of the household's holdings of unit trusts and investment trusts " + "outside ISAs. The Wealth and Assets Survey asks about both in one " + "question, so the Enhanced FRS cannot separate them. Part of " + "corporate_wealth, so the two must never be summed. Unit trusts are " + "valued for the means tests at the price the manager pays on " + "withdrawal, with no deduction for expenses of sale (Advice for " + "Decision Making H1673-H1674)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 From ae3e5e0eaacc6b5c4fd9e827bf569cc51b172d17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 14:30:01 -0400 Subject: [PATCH 004/224] Assess each Council Tax Reduction claim under its own family's scheme and exemption In a shared-rent household every family liable for the rent claims, but the pensioner scheme and the non-dependant exemption were picked once per household from the head's family. SI 2012/2885 reg 3 (and SI 2012/2886 Sch para 3, SI 2013/3029 reg 3, SSI 2021/249 reg 3, SSI 2012/319 reg 12) define "pensioner" per person and partner, and the exemption applies where "the applicant or his partner" is blind or gets a listed benefit (SI 2012/2885 Sch 1 para 8(6) and equivalents). - council_tax_reduction_pensioner (BenUnit) replaces the household flag; the name and entity match #1944 and #1966, which extend the same definition. - council_tax_reduction_applicant_has_non_dep_exemption (BenUnit) replaces the household exemption; the councils' schemes apply it per claim. - council_tax_reduction_scheme_supported is per family, so each family falls back to its reported reduction only where its own scheme is unmodelled. - Councils' person-level non-dependant deductions no longer depend on the head's family; each claimant's own scheme decides whether to use them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ctr-per-family-scheme.fixed.md | 1 + .../claimant_family_scheme.yaml | 461 ++++++++++++++++++ .../claimant_household_head.yaml | 14 +- .../legacy_claimant_non_dep_exemption.yaml | 16 +- ...ction_claimant_family_scheme_properties.py | 300 ++++++++++++ .../variables/gov/dwp/council_tax_benefit.py | 2 +- .../council_tax_reduction/_legacy.py | 26 +- .../council_tax_reduction/config.py | 39 +- ...duction_applicant_has_non_dep_exemption.py | 43 ++ ...duction_household_has_non_dep_exemption.py | 32 -- ...l_tax_reduction_household_has_pensioner.py | 13 - .../council_tax_reduction_pensioner.py | 34 ++ .../council_tax_reduction_scheme_supported.py | 21 +- ...simulated_council_tax_reduction_benunit.py | 5 +- ...gston_upon_thames_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../merton_council_tax_reduction.py | 4 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../newham_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../oxford_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- .../westminster_council_tax_reduction.py | 2 +- ..._reduction_individual_non_dep_deduction.py | 8 +- 24 files changed, 944 insertions(+), 115 deletions(-) create mode 100644 changelog.d/ctr-per-family-scheme.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py diff --git a/changelog.d/ctr-per-family-scheme.fixed.md b/changelog.d/ctr-per-family-scheme.fixed.md new file mode 100644 index 0000000000..9ded365e0b --- /dev/null +++ b/changelog.d/ctr-per-family-scheme.fixed.md @@ -0,0 +1 @@ +Assess each family that claims Council Tax Reduction under its own scheme and its own non-dependant exemption, so in a shared-rent household a working-age sharer no longer takes the pensioner scheme because the household head's family is pension-age (or the reverse), and one claimant's disability no longer exempts another's claim from non-dependant deductions. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml new file mode 100644 index 0000000000..c364aad9fd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -0,0 +1,461 @@ +# Where families share the rent, each jointly liable family claims Council Tax +# Reduction on its own share, and its claim follows its own circumstances: +# - the scheme: a "pensioner" is defined per person and partner (SI 2012/2885 +# reg 3; SI 2012/2886 Sch para 3; SI 2013/3029 reg 3; SSI 2021/249 reg 3; +# SSI 2012/319 reg 12), so a working-age family takes the working-age rules +# even if the household head's family is pension-age, and the reverse; +# - the non-dependant exemption: no deduction if "the applicant or [their] +# partner" is blind or gets a listed disability benefit (SI 2012/2885 Sch 1 +# para 8(6); SI 2012/2886 Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and +# Sch 6 para 5(6); SSI 2021/249 reg 90(6); SSI 2012/319 reg 48(6)). +# Each family here claims half of a 1,800 bill: 900. + +- name: Oxford, pension-age head and working-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # 500 a week. + council_tax_reduction_applicable_income: 26_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_claimant_benunit: [true, true] + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + # Oxford's working-age scheme: 500 a week is in the 75% band, 900 x 75%. + oxford_council_tax_reduction: [0, 675] + # The head takes the English pensioner scheme: no income, 900. + simulated_council_tax_reduction_benunit: [900, 675] + council_tax_benefit: [900, 675] + council_tax_reduction: 1_575 + +- name: Oxford, working-age head and pension-age sharer each claim under their own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 26_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: OXFORD + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + oxford_council_tax_reduction: [675, 0] + # The sharer takes the English pensioner scheme, not Oxford's income + # bands (which would give 100% at 288 a week): the single pension-age + # applicable amount is 13,312 a year (256.00 a week x 52), so + # 900 - 20% x (15,000 - 13,312) = 562.40. + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [675, 562.40] + council_tax_reduction: 1_237.40 + +- name: Merton, a non-dependant of both claimants is deducted under each claimant's own scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MERTON + output: + council_tax_reduction_pensioner: [true, false, false] + council_tax_reduction_joint_liability_share: [0.5, 0.5, 1] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + # The son's gross 576.92 a week is in Merton's 485-605 band: 13.30 a week + # (691.60 a year), whatever the head's age. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + # The son is a non-dependant of both: each claim bears half of him. + merton_council_tax_reduction_non_dep_deductions: [345.80, 345.80, 0] + # The sharer's claim is under Merton's working-age scheme: + # 900 - 345.80 = 554.20. The head's is under the pensioner scheme, with + # the national non-dependant amounts. + merton_council_tax_reduction: [0, 554.20, 0] + +- name: An unmodelled council's working-age sharer keeps its reported reduction beside a pension-age head + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 300 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [true, false] + # Maidstone's working-age scheme is not modelled; the pensioner scheme is. + council_tax_reduction_scheme_supported: [true, false] + simulated_council_tax_reduction_benunit: [900, 0] + council_tax_benefit: [900, 300] + council_tax_reduction: 1_200 + +- name: An unmodelled council's working-age head keeps its reported reduction beside a pension-age sharer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] + simulated_council_tax_reduction_benunit: [0, 900] + council_tax_benefit: [300, 900] + council_tax_reduction: 1_200 + +- name: Newham, the head's disability exempts only the head's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 35 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + council_tax_reduction_pensioner: [false, false, false] + # The son's gross 576.92 a week is in Newham's top band, 22.61 a week + # (1,175.72 a year); the sharer bears half, the head none. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 0, 1_175.72] + newham_council_tax_reduction_non_dep_deductions: [0, 587.86, 0] + +- name: Newham, the sharer's disability exempts only the sharer's claim from non-dependant deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 35 + pip_dl: 3_000 + adult_son: + age: 25 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + son_family: + members: [adult_son] + would_claim_uc: false + households: + home: + members: [head, sharer, adult_son] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: NEWHAM + output: + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + newham_council_tax_reduction_non_dep_deductions: [587.86, 0, 0] + +- name: Wales, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + attendance_allowance: 4_000 + sharer: + age: 40 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + # Each family's own applicable amount: pension-age 256.00 a week, the + # working-age single 25 or over 95.55 a week. + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + # 900 - 20% x (15,000 - 13,312) = 562.40; + # 900 - 20% x (8,000 - 4,968.60) = 293.72. + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Wales, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + sharer: + age: 70 + attendance_allowance: 4_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 + +- name: Scotland, pension-age head and working-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + pip_dl: 3_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] + council_tax_reduction_applicable_amount: [13_312, 4_968.60] + simulated_council_tax_reduction_benunit: [562.40, 293.72] + council_tax_reduction: 856.12 + +- name: Scotland, working-age head and pension-age sharer are each assessed on their own family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + pip_dl: 3_000 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 8_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + council_tax_reduction_applicable_income: 15_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + council_tax_reduction_applicable_amount: [4_968.60, 13_312] + simulated_council_tax_reduction_benunit: [293.72, 562.40] + council_tax_reduction: 856.12 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index e41a7d5eb3..69b7214a85 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -301,7 +301,9 @@ # The eldest adult is not the liable resident; the head is. benunit_contains_household_head: [true, false] council_tax_reduction_claimant_benunit: [true, false] - council_tax_reduction_household_has_pensioner: false + # The parent's family is pension-age, but it is not liable and claims + # nothing; the householder's claim follows their own family. + council_tax_reduction_pensioner: [false, true] council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] council_tax_reduction_non_dep_deductions: [200, 0] simulated_council_tax_reduction_benunit: [1_600, 0] @@ -334,13 +336,15 @@ savings: 0 output: # The claimant's family is working-age; Maidstone's working-age scheme is - # not modelled, so no simulated reduction. - council_tax_reduction_household_has_pensioner: false - council_tax_reduction_scheme_supported: false + # not modelled, so no simulated reduction. The parent's family would be on + # the pensioner scheme, but it is not liable and claims nothing. + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_scheme_supported: [false, true] simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [0, 0] # A non-dependant's Attendance Allowance does not exempt the claimant # from non-dependant deductions (SI 2012/2885 Sch 1 para 8(6)). - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, true] - name: With two members flagged as head, the eldest flagged member's family claims period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml index ec6636d1fa..9367281a5e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_claimant_non_dep_exemption.yaml @@ -1,4 +1,4 @@ -- name: Dependent young person PIP does not exempt the claimant household +- name: Dependent young person PIP does not exempt the claimant's family period: 2025 input: people: @@ -21,9 +21,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] -- name: Under-18 partner PIP exempts the claimant household +- name: Under-18 partner PIP exempts the claimant's family period: 2025 input: people: @@ -50,9 +50,9 @@ output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. is_couple: [true, false] - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Claimant PIP continues to exempt the household +- name: Claimant PIP continues to exempt the claimant's family period: 2025 input: people: @@ -75,9 +75,9 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] -- name: Dependent young person blindness does not exempt the claimant household +- name: Dependent young person blindness does not exempt the claimant's family period: 2025 input: people: @@ -100,4 +100,4 @@ members: [claimant, other_member, non_dependant] output: # CTR SI 2012/2885 Sch 1 para 8(6): only applicant/partner benefits count. - council_tax_reduction_household_has_non_dep_exemption: false + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py new file mode 100644 index 0000000000..e6bac255c1 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -0,0 +1,300 @@ +"""Property-based tests: each family that claims Council Tax Reduction is +assessed under its own scheme and its own non-dependant exemption. + +Where the household's rent is shared, every family liable for it claims, and +each claim follows the applicant's own circumstances. "Pensioner" is defined +per person and partner (SI 2012/2885 reg 3(1); SI 2012/2886 Sch para 3(2); +SI 2013/3029 reg 3(1); SSI 2021/249 reg 3; SSI 2012/319 reg 12). No +non-dependant deduction is made "if the applicant or his partner" is blind or +gets a listed disability benefit (SI 2012/2885 Sch 1 para 8(6); SI 2012/2886 +Sch para 30(6); SI 2013/3029 Sch 1 para 3(6) and Sch 6 para 5(6); SSI +2021/249 reg 90(6); SSI 2012/319 reg 48(6)). + +Invariants, for any generated population of households with a flagged head, +up to two sharer families and at most one non-dependant family: + +1. Own members: a family is a pensioner exactly when its claimant or partner + has reached State Pension age. It is exempt from non-dependant deductions + exactly when its claimant or partner is blind or gets Attendance + Allowance, the care component of Disability Living Allowance, the daily + living component of Personal Independence Payment or Armed Forces + Independence Payment. +2. As if alone: each family's pensioner status, exemption and whether its + scheme is simulated are what they would be if it lived alone. +3. Scheme: a family's scheme is simulated exactly when it lives in Scotland + or Wales, is a pensioner, or lives in a council whose working-age scheme + is modelled. In England only a pensioner family is paid by the national + pensioner scheme, and only a working-age family by a council's scheme. +4. Others do not matter: changing the ages and disability benefits of every + other family that claims leaves a family's pensioner status, exemption, + scheme and award unchanged. +5. Bounds: a family that cannot claim gets no simulated reduction, and the + household's reduction never exceeds its council tax. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +MODELLED_WORKING_AGE_SCHEMES = { + "MERTON", + "KINGSTON_UPON_THAMES", + "NEWHAM", + "WESTMINSTER", + "OXFORD", +} +LOCAL_SCHEMES = [ + "merton_council_tax_reduction", + "kingston_upon_thames_council_tax_reduction", + "newham_council_tax_reduction", + "westminster_council_tax_reduction", + "oxford_council_tax_reduction", +] +# Inputs that exempt the applicant from non-dependant deductions, as an +# annual amount (or a flag for blindness). +DISABILITY = { + "attendance_allowance": 5_000, + "dla_sc": 3_500, + "pip_dl": 4_000, + "armed_forces_independence_payment": 9_000, + "is_blind": True, +} +# Ages of 20 or more are never presumed to be a child of the family, so +# changing them never changes who is a claimant or partner. The sampled values +# sit on both sides of State Pension age. +adult_age = st.one_of( + st.sampled_from([20, 30, 45, 64, 65, 66, 67, 70, 85]), st.integers(20, 100) +) +disability = st.sampled_from([None, *DISABILITY]) +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) + + +@st.composite +def adults(draw, most): + n = draw(st.integers(1, most)) + return dict( + ages=draw(st.lists(adult_age, min_size=n, max_size=n)), + disabilities=draw(st.lists(disability, min_size=n, max_size=n)), + earnings=draw(st.lists(money, min_size=n, max_size=n)), + ) + + +@st.composite +def family(draw, role): + # A non-dependant here is one adult with no children. + alone = role == "non_dependant" + return dict( + role=role, + child_age=None if alone else draw(st.one_of(st.none(), st.integers(0, 15))), + **draw(adults(1 if alone else 2)), + ) + + +@st.composite +def households(draw): + families = [draw(family("head"))] + families += [draw(family("sharer")) for _ in range(draw(st.integers(0, 2)))] + if draw(st.booleans()): + families.append(draw(family("non_dependant"))) + claimants = sum(f["role"] != "non_dependant" for f in families) + return dict( + families=families, + scheme=draw(st.sampled_from(SCHEMES)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + rent=draw(money), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + target=draw(st.integers(0, claimants - 1)), + # Replacement circumstances for the other families that claim. + other_ages=draw(st.lists(adult_age, min_size=6, max_size=6)), + other_disabilities=draw(st.lists(disability, min_size=6, max_size=6)), + ) + + +population = st.lists(households(), min_size=1, max_size=6) + + +def person(age, disability_input, earnings): + attributes = {"age": age, "employment_income": earnings} + if disability_input is not None: + attributes[disability_input] = DISABILITY[disability_input] + return attributes + + +def build(population, alone=False, perturb_others=False): + """One situation for the whole population, plus per-family facts. + + alone: put every family in a household of its own, as its head. + perturb_others: give every family that claims, other than each + household's target, the household's replacement ages and disabilities. + """ + people, benunits, homes = {}, {}, {} + facts = dict(country=[], local_authority=[], role=[], target=[], house=[]) + for h, house in enumerate(population): + country, local_authority = house["scheme"] + home = dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + rent=house["rent"], + tenure_type="RENT_PRIVATELY", + savings=house["savings"], + ) + members, replacement = [], 0 + for f, fam in enumerate(house["families"]): + ids = [] + claims = fam["role"] != "non_dependant" + is_target = claims and f == house["target"] + for i, age in enumerate(fam["ages"]): + pid = f"h{h}_f{f}_adult_{i}" + disability_input = fam["disabilities"][i] + if perturb_others and claims and not is_target: + age = house["other_ages"][replacement] + disability_input = house["other_disabilities"][replacement] + replacement += 1 + people[pid] = person(age, disability_input, fam["earnings"][i]) + people[pid]["is_household_head"] = ( + alone or fam["role"] == "head" + ) and i == 0 + ids.append(pid) + if fam["child_age"] is not None: + pid = f"h{h}_f{f}_child" + people[pid] = {"age": fam["child_age"], "is_household_head": False} + ids.append(pid) + benunits[f"h{h}_f{f}"] = { + "members": ids, + "liable_for_share_of_household_rent": fam["role"] == "sharer" + and not alone, + "claims_all_entitled_benefits": True, + "would_claim_uc": True, + } + if alone: + homes[f"h{h}_f{f}"] = {"members": ids, **home} + members.extend(ids) + facts["country"].append(country) + facts["local_authority"].append(local_authority) + facts["role"].append(fam["role"]) + facts["target"].append(is_target) + facts["house"].append(h) + if not alone: + homes[f"h{h}"] = {"members": members, **home} + situation = { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", homes), + ) + } + return situation, {k: np.array(v) for k, v in facts.items()} + + +def calc(simulation, variable, map_to=None): + return np.asarray(simulation.calculate(variable, YEAR, map_to=map_to)) + + +def any_in_benunit(simulation, person_values): + return simulation.map_result(person_values.astype(float), "person", "benunit") > 0 + + +FLAGS = [ + "council_tax_reduction_pensioner", + "council_tax_reduction_applicant_has_non_dep_exemption", + "council_tax_reduction_scheme_supported", +] + + +@PROPERTY_SETTINGS +@given(population) +def test_scheme_follows_own_family(population): + situation, facts = build(population) + sim = Simulation(situation=situation) + pensioner = calc(sim, "council_tax_reduction_pensioner") + exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") + supported = calc(sim, "council_tax_reduction_scheme_supported") + claimant = calc(sim, "council_tax_reduction_claimant_benunit") + + # 1. Own members. + claimant_or_partner = calc(sim, "is_claimant_or_partner") + assert np.array_equal( + pensioner, + any_in_benunit(sim, claimant_or_partner & calc(sim, "is_SP_age")), + ) + exempting = calc(sim, "is_blind").astype(bool) + for variable in DISABILITY: + if variable != "is_blind": + exempting |= calc(sim, variable) > 0 + assert np.array_equal(exempt, any_in_benunit(sim, claimant_or_partner & exempting)) + + # 2. As if alone. + alone_situation, alone_facts = build(population, alone=True) + alone = Simulation(situation=alone_situation) + assert np.array_equal(alone_facts["role"], facts["role"]) + for variable in FLAGS: + assert np.array_equal(calc(sim, variable), calc(alone, variable)), variable + + # 3. Scheme. + england = facts["country"] == "ENGLAND" + modelled = np.isin(facts["local_authority"], list(MODELLED_WORKING_AGE_SCHEMES)) + assert np.array_equal(supported, ~england | pensioner | modelled) + simulated = calc(sim, "simulated_council_tax_reduction_benunit") + local = sum(calc(sim, variable) for variable in LOCAL_SCHEMES) + national = simulated - local + assert not np.any((national > 0.005) & england & ~pensioner) + assert not np.any((local > 0.005) & pensioner) + assert not np.any((local > 0.005) & ~england) + + # 5. Bounds. (No family has a reported reduction to fall back on here.) + assert np.all(simulated[~claimant] == 0) + household_reduction = calc(sim, "council_tax_reduction") + assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + + +@PROPERTY_SETTINGS +@given(population) +def test_other_families_do_not_change_a_claim(population): + situation, facts = build(population) + perturbed_situation, _ = build(population, perturb_others=True) + before = Simulation(situation=situation) + after = Simulation(situation=perturbed_situation) + target = facts["target"] + # 4. Others do not matter. + for variable in FLAGS + LOCAL_SCHEMES: + assert np.array_equal( + calc(before, variable)[target], calc(after, variable)[target] + ), variable + for variable in ( + "council_tax_reduction_joint_liability_share", + "simulated_council_tax_reduction_benunit", + "council_tax_benefit", + ): + np.testing.assert_allclose( + calc(before, variable)[target], + calc(after, variable)[target], + atol=1e-6, + err_msg=variable, + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index d2861ead5b..b80f01b1f0 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -9,7 +9,7 @@ class council_tax_benefit(Variable): unit = GBP def formula(benunit, period, parameters): - supported = benunit.household("council_tax_reduction_scheme_supported", period) + supported = benunit("council_tax_reduction_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) return where(supported, simulated, reported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 966d230ff7..599c266543 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -91,17 +91,31 @@ def local_non_dep_deductions( # A non-dependant of two or more jointly liable people is apportioned # equally between them (SI 2012/2885 Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - return (deductions_in_household - deduction_for_benunit) * share + # No deduction from an applicant who, or whose partner, is blind or gets a + # qualifying disability benefit, whatever other claimants in the household + # get (the councils' schemes follow SI 2012/2886 Sch para 30(6)). + applicant_exempt = benunit( + "council_tax_reduction_applicant_has_non_dep_exemption", period + ) + return where( + applicant_exempt, 0, (deductions_in_household - deduction_for_benunit) * share + ) def normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=True, exempt_uc_no_earned_income=True, ): + """The deduction a non-dependant brings under one council's scheme. + + It depends on the non-dependant alone. Whether a claimant's award uses it + (the claimant's own scheme) and whether the claimant is exempt from + non-dependant deductions are decided per claimant family. + """ gross_income_components = [ "employment_income", "self_employment_income", @@ -128,9 +142,6 @@ def normal_gross_income_non_dep_deduction( ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), ctr.non_dep_deduction.amount.calc(0), ) - claimant_exempt = person.household( - "council_tax_reduction_household_has_non_dep_exemption", period - ) full_time_student = is_full_time_student_non_dep(person, period) income_based_benefit = ( (person.benunit("income_support", period) > 0) @@ -141,9 +152,8 @@ def normal_gross_income_non_dep_deduction( has_uc = person.benunit("universal_credit", period) > 0 no_earned_income = weekly_benunit_earned_income <= 0 exempt = ( - claimant_exempt - | full_time_student + full_time_student | (exempt_income_based_benefits & income_based_benefit) | (exempt_uc_no_earned_income & has_uc & no_earned_income) ) - return working_age * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) + return in_scheme_area * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index 1ee8ea5cbc..a28e7a3a68 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -2,6 +2,9 @@ from policyengine_uk.variables.household.demographic.country import Country from policyengine_uk.variables.household.demographic.locations import LocalAuthority +# Scheme selection takes has_pensioner per claim: whether the applicant's own +# family is a pensioner for Council Tax Reduction (council_tax_reduction_pensioner). + def is_england_pensioner_scheme(country, has_pensioner): return (country == Country.ENGLAND) & has_pensioner @@ -19,46 +22,60 @@ def is_merton(local_authority): return local_authority == LocalAuthority.MERTON +def is_merton_area(local_authority, country): + return (country == Country.ENGLAND) & is_merton(local_authority) + + def is_merton_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_merton(local_authority) + return is_merton_area(local_authority, country) & ~has_pensioner def is_kingston_upon_thames(local_authority): return local_authority == LocalAuthority.KINGSTON_UPON_THAMES +def is_kingston_upon_thames_area(local_authority, country): + return (country == Country.ENGLAND) & is_kingston_upon_thames(local_authority) + + def is_kingston_upon_thames_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_kingston_upon_thames(local_authority) - ) + return is_kingston_upon_thames_area(local_authority, country) & ~has_pensioner def is_newham(local_authority): return local_authority == LocalAuthority.NEWHAM +def is_newham_area(local_authority, country): + return (country == Country.ENGLAND) & is_newham(local_authority) + + def is_newham_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_newham(local_authority) + return is_newham_area(local_authority, country) & ~has_pensioner def is_westminster(local_authority): return local_authority == LocalAuthority.WESTMINSTER +def is_westminster_area(local_authority, country): + return (country == Country.ENGLAND) & is_westminster(local_authority) + + def is_westminster_working_age(local_authority, country, has_pensioner): - return ( - (country == Country.ENGLAND) & ~has_pensioner & is_westminster(local_authority) - ) + return is_westminster_area(local_authority, country) & ~has_pensioner def is_oxford(local_authority): return local_authority == LocalAuthority.OXFORD +def is_oxford_area(local_authority, country): + return (country == Country.ENGLAND) & is_oxford(local_authority) + + def is_oxford_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_oxford(local_authority) + return is_oxford_area(local_authority, country) & ~has_pensioner def is_supported_scheme(country, has_pensioner, local_authority): diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py new file mode 100644 index 0000000000..4a539258a2 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "No non-dependant deductions from this family's Council Tax Reduction" + documentation = ( + "Whether no deduction is made for any non-dependant from this family's " + "Council Tax Reduction because the applicant or partner is blind, or " + "receives in respect of themselves Attendance Allowance, the care " + "component of Disability Living Allowance, the daily living component " + "of Personal Independence Payment or Armed Forces Independence Payment. " + "The exemption belongs to each applicant: where families share the " + "rent and each claims, one family's disability does not exempt " + "another's claim. The model applies it in the English working-age " + "local schemes." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 Sch 1 para 8(6): "No deduction is to be made in respect + # of any non-dependants occupying an applicant's dwelling if the + # applicant or his partner is" blind or receiving one of the listed + # benefits "in respect of himself". + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + exempting = ( + person("is_blind", period) + | (person("attendance_allowance", period) > 0) + | (person("pip_dl", period) > 0) + | (person("dla_sc", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + ) + return benunit.any(claimant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py deleted file mode 100644 index 3ede96b204..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ /dev/null @@ -1,32 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_non_dep_exemption(Variable): - value_type = bool - entity = Household - label = "CTR household has a non-dependant deduction exemption" - definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8" - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - claimant_or_partner = claimant_benunit & person( - "is_claimant_or_partner", period - ) - is_blind = person("is_blind", period) & claimant_or_partner - attendance_allowance = ( - person("attendance_allowance", period) > 0 - ) & claimant_or_partner - pip_daily_living = (person("pip_dl", period) > 0) & claimant_or_partner - dla_care = (person("dla_sc", period) > 0) & claimant_or_partner - armed_forces_independence_payment = ( - person("armed_forces_independence_payment", period) > 0 - ) & claimant_or_partner - return household.any( - is_blind - | attendance_allowance - | pip_daily_living - | dla_care - | armed_forces_independence_payment - ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py deleted file mode 100644 index 555231a5ab..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py +++ /dev/null @@ -1,13 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_household_has_pensioner(Variable): - value_type = bool - entity = Household - label = "CTR claimant benefit unit has a pension-age member" - definition_period = YEAR - - def formula(household, period, parameters): - person = household.members - claimant_benunit = person.benunit("benunit_contains_household_head", period) - return household.any(claimant_benunit & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py new file mode 100644 index 0000000000..d958b9f680 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_pensioner(Variable): + value_type = bool + entity = BenUnit + label = "Pensioner for Council Tax Reduction" + documentation = ( + "Whether this family's claim falls under the pension-age Council Tax " + "Reduction rules: the claimant or partner has reached State Pension " + "age. Each applicant's scheme follows their own family, so where " + "families share the rent and each claims, a working-age family is " + "assessed under the working-age rules even if the household head's " + "family is pension-age, and the reverse. The further condition that " + "neither the claimant nor a partner is on Income Support, income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance or Universal Credit is not modelled here." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", + ) + + def formula(benunit, period, parameters): + # SI 2012/2885 reg 3(1)(a)(i): "he has attained the qualifying age for + # state pension credit". is_SP_age stands in for that age, as + # elsewhere in the model. + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(claimant_or_partner & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index 9fb0699fa0..c559fe9bb9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -6,14 +6,21 @@ class council_tax_reduction_scheme_supported(Variable): value_type = bool - entity = Household + entity = BenUnit label = "Supported CTR scheme is available" + documentation = ( + "Whether the model simulates the Council Tax Reduction scheme that " + "applies to this family's claim: the pension-age scheme in England, " + "the Scottish and Welsh schemes, and the working-age schemes of the " + "English councils it models. The scheme follows the family's own " + "pensioner status, so families in one household can fall under " + "different schemes. A family whose scheme is not simulated keeps its " + "reported reduction." + ) definition_period = YEAR - def formula(household, period, parameters): - country = household("country", period) - has_pensioner = household( - "council_tax_reduction_household_has_pensioner", period - ) - local_authority = household("local_authority", period) + def formula(benunit, period, parameters): + country = benunit.household("country", period) + has_pensioner = benunit("council_tax_reduction_pensioner", period) + local_authority = benunit.household("local_authority", period) return is_supported_scheme(country, has_pensioner, local_authority) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 6f3714fee8..91fdb6cf6f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -30,9 +30,8 @@ def formula(benunit, period, parameters): wales_ctr = local_authority_parameters.wales.council_tax_reduction country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + # Each claim's scheme follows the applicant's own family. + has_pensioner = benunit("council_tax_reduction_pensioner", period) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) scotland = is_scotland_scheme(country) wales = is_wales_scheme(country) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index 7f9b1b7663..46afbc7d86 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -23,7 +23,7 @@ def formula(benunit, period, parameters): working_age = is_kingston_upon_thames_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py index c8ae7f4df0..616274593b 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_kingston_upon_thames_working_age, + is_kingston_upon_thames_area, ) @@ -20,15 +20,15 @@ def formula(person, period, parameters): period ).gov.local_authorities.kingston_upon_thames.council_tax_reduction household = person.household - working_age = is_kingston_upon_thames_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_kingston_upon_thames_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py index 03012a5f8a..ef6ec3b1c5 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py @@ -18,9 +18,7 @@ def formula(benunit, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction local_authority = benunit.household("local_authority", period) country = benunit.household("country", period) - has_pensioner = benunit.household( - "council_tax_reduction_household_has_pensioner", period - ) + has_pensioner = benunit("council_tax_reduction_pensioner", period) working_age = is_merton_working_age(local_authority, country, has_pensioner) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py index 2907e2a700..8023cec88d 100644 --- a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_merton_working_age, + is_merton_area, ) @@ -18,15 +18,15 @@ class merton_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction household = person.household - working_age = is_merton_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_merton_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py index c35482476f..4abcbf6e62 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction.py @@ -20,7 +20,7 @@ def formula(benunit, period, parameters): working_age = is_newham_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) return legacy_council_tax_reduction( benunit, diff --git a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py index 0e4b9db546..465b7b5a78 100644 --- a/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/newham/council_tax_reduction/newham_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_newham_working_age, + is_newham_area, ) @@ -18,15 +18,15 @@ class newham_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.newham.council_tax_reduction household = person.household - working_age = is_newham_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_newham_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index abeea551dc..208dab3929 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -18,7 +18,7 @@ def formula(benunit, period, parameters): working_age = is_oxford_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) is_household_head_benunit = benunit( "council_tax_reduction_claimant_benunit", period diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py index 61c80cecbb..64452ff088 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_oxford_working_age, + is_oxford_area, ) @@ -18,16 +18,16 @@ class oxford_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.oxford.council_tax_reduction household = person.household - working_age = is_oxford_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_oxford_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_income_based_benefits=False, exempt_uc_no_earned_income=False, ) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index 3c86931aee..eaad011382 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -21,7 +21,7 @@ def formula(benunit, period, parameters): working_age = is_westminster_working_age( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), + benunit("council_tax_reduction_pensioner", period), ) has_uc_award = benunit("universal_credit", period) > 0 capital = household("savings", period) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py index d5666a67a7..86533c51d7 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_individual_non_dep_deduction.py @@ -3,7 +3,7 @@ normal_gross_income_non_dep_deduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - is_westminster_working_age, + is_westminster_area, ) @@ -18,15 +18,15 @@ class westminster_council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): ctr = parameters(period).gov.local_authorities.westminster.council_tax_reduction household = person.household - working_age = is_westminster_working_age( + # Each claimant's own scheme decides whether their award uses this. + in_scheme_area = is_westminster_area( household("local_authority", period), household("country", period), - household("council_tax_reduction_household_has_pensioner", period), ) return normal_gross_income_non_dep_deduction( person, period, ctr, - working_age, + in_scheme_area, exempt_uc_no_earned_income=True, ) From 45719004f8e0e34c402a026eb0b0096a959a29d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:03:54 -0400 Subject: [PATCH 005/224] WIP: assess the Council Tax Reduction applicant and partner in the means test Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 440 ++++++++++++++++++ ...ncil_tax_reduction_applicant_properties.py | 382 +++++++++++++++ .../council_tax_reduction/_legacy.py | 11 +- ...council_tax_reduction_applicable_amount.py | 30 +- ...council_tax_reduction_applicable_income.py | 43 +- ...ouncil_tax_reduction_head_applies_alone.py | 38 ++ ...duction_household_has_non_dep_exemption.py | 11 +- ...l_tax_reduction_household_has_pensioner.py | 19 +- .../council_tax_reduction_premiums.py | 51 ++ ...reduction_relevant_income_based_benefit.py | 14 +- ...ncil_tax_reduction_applicant_or_partner.py | 46 ++ ...gston_upon_thames_council_tax_reduction.py | 3 +- .../oxford_council_tax_reduction.py | 12 +- .../westminster_council_tax_reduction.py | 3 +- 14 files changed, 1077 insertions(+), 26 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml new file mode 100644 index 0000000000..bf25b60348 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -0,0 +1,440 @@ +# The Council Tax Reduction means test covers the applicant's own income and +# their partner's (SSI 2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 +# para 11; WSI 2013/3029 Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36). +# The applicant is the person liable for the council tax. Where that is a +# household head who is not the benefit unit's claimant or partner (here a +# grandmother sharing a benefit unit with a young couple and their baby), the +# means test is the head's own, as a single person; the benefit unit's +# benefits belong to the couple. None of these cases sets applicable income. +# +# Rates for 2026 (parameters): pension-age single personal allowance +# £256/week, working-age single aged 25 or over £95.55/week, couple aged 18 +# or over £150.15/week, disability premium (single) £44.85/week, severe +# disability premium (single) £86.05/week; 52 weeks. Scotland, Wales and the +# English pensioner scheme: maximum reduction 100%, withdrawal 20%. Newham: +# maximum 70%, withdrawal 25%. Personal allowance £12,570, basic rate 20%. + +- name: A grandmother head with a young couple in her benefit unit and no income of her own gets the full reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + council_tax_reduction_head_applies_alone: [true] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + # The couple's Child Benefit for the baby is theirs, not hers. + council_tax_reduction_applicable_income: [0] + # A single pensioner: 256 * 52. + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + council_tax_reduction: 1_800 + +- name: The same grandmother with a £100,000 private pension gets no reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 100_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_applicable_amount: [13_312] + # Her pension after tax is far above £13,312, and 20% of the excess is + # more than the £1,800 council tax. + simulated_council_tax_reduction_benunit: [0] + council_tax_reduction: 0 + +- name: In England a grandmother head's own £20,000 pension is withdrawn at 20% above her allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + employment_income: 10_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_household_has_pensioner: true + # Her pension less income tax of (20,000 - 12,570) * 20% = 1,486. The + # young mother's earnings are not hers. + council_tax_reduction_applicable_income: [18_514] + council_tax_reduction_applicable_amount: [13_312] + # 1,800 - (18,514 - 13,312) * 20%. + simulated_council_tax_reduction_benunit: [759.60] + +- name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + jsa_income: 4_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_relevant_income_based_benefit: [false] + # Neither the couple's JSA nor their Child Benefit is her income. + council_tax_reduction_applicable_income: [18_514] + simulated_council_tax_reduction_benunit: [759.60] + +- name: A head who applies alone is a single person, with premiums on her own disability only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_disabled_for_benefits: true + is_severely_disabled_for_benefits: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + is_disabled_for_benefits: true + is_carer_for_benefits: true + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # Disability (44.85) and severe disability (86.05) premiums at the single + # rate: (44.85 + 86.05) * 52. The young mother's disability and caring + # do not give the grandmother a premium. + council_tax_reduction_premiums: [6_806.80] + # Single aged 25 or over, 95.55 * 52 = 4,968.60, plus the premiums. + council_tax_reduction_applicable_amount: [11_775.40] + +- name: A blind grandmother head who applies alone has no non-dependant deduction in Merton + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_blind: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + son: + age: 30 + is_household_head: false + employment_income: 30_000 + weekly_hours: 40 + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + son: + members: [son] + claims_all_entitled_benefits: false + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_household_has_pensioner: false + # The exemption is the applicant's or partner's (SI 2012/2885 Sch 1 para + # 8(6); the Merton scheme follows it). + council_tax_reduction_household_has_non_dep_exemption: true + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, false, true] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + merton_council_tax_reduction: [1_800, 0] + +- name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_applicable_income: [0] + # Single aged 25 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: [4_968.60] + # No excess income: 1,800 * 70%. + newham_council_tax_reduction: [1_260] + +- name: A partner under 18 is not liable, but their income counts in the couple's means test + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_benunit_head: false + partner: + # The benefit unit's head, so the HBAI fallback does not read the + # 17-year-old as a dependent child. + age: 17 + is_household_head: false + is_benunit_head: true + employment_income: 10_000 + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [true, false] + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [true, true] + # Below the personal allowance and the National Insurance threshold. + council_tax_reduction_applicable_income: [10_000] + # Couple aged 18 or over: 150.15 * 52 = 7,807.80. + council_tax_reduction_applicable_amount: [7_807.80] + # 1,800 - (10,000 - 7,807.80) * 20%. + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A head under 18 with an adult partner claims through the partner, and the head's income counts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 17 + is_household_head: true + is_benunit_head: true + employment_income: 10_000 + partner: + age: 18 + is_household_head: false + is_benunit_head: false + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [false, true] + is_council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicable_income: [10_000] + council_tax_reduction_applicable_amount: [7_807.80] + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A pensioner who is not the applicant or partner does not make a young couple's claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: false + mother: + age: 19 + is_parent: true + is_household_head: true + father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, mother, father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The mother is the head and the couple are the benefit unit's claimant + # and partner, so they are the applicant and partner. + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [false, true, true, false] + council_tax_reduction_household_has_pensioner: false + # A working-age couple aged 18 or over: 150.15 * 52, not the pension-age + # couple rate. + council_tax_reduction_applicable_amount: [7_807.80] diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py new file mode 100644 index 0000000000..895d3b2607 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -0,0 +1,382 @@ +"""Property-based tests for whose circumstances the Council Tax Reduction +means test assesses. + +The means test covers the applicant's own income and their partner's (SSI +2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 para 11; WSI 2013/3029 +Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36), and the applicant is the +person liable for the council tax. The model takes the household head as +liable (council_tax_reduction_liable_person). Usually the head is the +benefit unit's claimant or partner, but a grandmother who heads the household +can share a benefit unit with a young couple who are its claimant and +partner (#1896's parent-couple rule). She then applies alone. + +Invariants, for any generated population of households: heads alone, in a +couple (with partners aged 16 and over), or sharing a benefit unit with two +parents aged 16 to 30 and their baby; another family, sometimes a sharer; +income-based Jobseeker's Allowance and Universal Credit supplied for the +head's benefit unit; disability and caring flags; all eight modelled schemes. + +1. Head's income counts: raising the liable household head's own private + pension by £5,000 raises their family's applicable income. Raising a + young parent's earnings by £5,000 leaves the family's applicable income + unchanged where the head applies alone. +2. Monotone: raising the head's pension never raises any family's simulated + reduction. +3. High income: with £250,000 more pension, the head's family gets no + simulated reduction, unless an income-based benefit passports it or its + applicant has a Universal Credit award (both supplied as inputs here; + those routes are outside this invariant). +4. Identity: a liable head is always an applicant or partner of their own + family. Where the head does not apply alone, the applicant and partner + are exactly the benefit unit's claimant and partner (ordinary families + are unchanged). Where the head applies alone, the head is the only one. +5. Differential: a head over State Pension age sharing a benefit unit with + parents aged 16 or 17 and their baby has the same applicable income, + applicable amount, premiums, scheme, exemption and simulated reduction as + the same head entered as her own benefit unit, with the young family as + another. The two inputs describe the same people, so the law gives the + same answer. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +SHAPES = ["alone", "couple", "young_parents"] +FLAGS = [ + "is_disabled_for_benefits", + "is_severely_disabled_for_benefits", + "is_carer_for_benefits", + "is_blind", +] +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +head_age = st.one_of( + st.sampled_from([18, 25, 40, 60, 66, 68, 80]), st.integers(18, 100) +) +PENSION_STEP = 5_000 +HIGH_PENSION = 250_000 + + +@st.composite +def households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + shape=draw(st.sampled_from(SHAPES)), + head_age=draw(head_age), + head_pension=draw(money), + head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), + partner_age=draw(st.integers(16, 100)), + parent_age=draw(st.integers(16, 30)), + other_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, None, 3_000])), + universal_credit=draw(st.sampled_from([None, None, 5_000])), + other_family=draw(st.sampled_from([None, "non_dependant", "sharer"])), + other_age=draw(st.integers(16, 90)), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + ) + + +def person_input(values): + return {key: {YEAR: value} for key, value in values.items()} + + +def add_household(people, benunits, homes, name, house, variant): + """Add one household. variant: "base", "pension", "high_pension" or + "parent_earnings" (the young mother's earnings raised).""" + country, local_authority = house["scheme"] + pension = house["head_pension"] + { + "pension": PENSION_STEP, + "high_pension": HIGH_PENSION, + }.get(variant, 0) + head = f"{name}_head" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=pension, + **house["head_flags"], + ) + ) + family = [head] + if house["shape"] == "couple": + people[f"{name}_partner"] = person_input( + dict( + age=house["partner_age"], + is_household_head=False, + employment_income=house["other_earnings"][0], + ) + ) + family.append(f"{name}_partner") + elif house["shape"] == "young_parents": + for i, parent in enumerate(["mother", "father"]): + earnings = house["other_earnings"][i] + if variant == "parent_earnings" and parent == "mother": + earnings += PENSION_STEP + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=earnings, + ) + ) + family.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + family.append(f"{name}_baby") + head_benunit = dict( + members=family, + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + ) + for benefit in ["jsa_income", "universal_credit"]: + if house[benefit] is not None: + head_benunit[benefit] = {YEAR: house[benefit]} + benunits[f"{name}_family"] = head_benunit + members = list(family) + if house["other_family"] is not None: + other = f"{name}_other" + people[other] = person_input( + dict( + age=house["other_age"], + is_household_head=False, + employment_income=house["other_earnings"][1], + ) + ) + benunits[f"{name}_other_family"] = dict( + members=[other], + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + liable_for_share_of_household_rent={ + YEAR: house["other_family"] == "sharer" + }, + ) + members.append(other) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = members + + +VARIANTS = ["base", "pension", "high_pension", "parent_earnings"] + + +def simulate(population): + """One simulation holding every household in every variant.""" + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for variant in VARIANTS: + add_household(people, benunits, homes, f"h{h}_{variant}", house, variant) + return Simulation( + situation=dict(people=people, benunits=benunits, households=homes) + ) + + +def by_benunit(sim, variable, names): + """A benefit-unit variable for the named benefit units.""" + values = sim.calculate(variable, YEAR) + ids = list(sim.populations["benunit"].ids) + return np.array([values[ids.index(name)] for name in names]) + + +@given(st.lists(households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_council_tax_reduction_assesses_the_liable_head(population): + sim = simulate(population) + head_family = { + variant: [f"h{h}_{variant}_family" for h in range(len(population))] + for variant in VARIANTS + } + + def family(variable, variant): + return by_benunit(sim, variable, head_family[variant]) + + head_alone = family("council_tax_reduction_head_applies_alone", "base") + + # 1. The head's pension counts; a young parent's earnings do not where + # the head applies alone. + income = family("council_tax_reduction_applicable_income", "base") + assert (family("council_tax_reduction_applicable_income", "pension") > income).all() + parent_income = family("council_tax_reduction_applicable_income", "parent_earnings") + assert np.allclose(parent_income[head_alone], income[head_alone]) + + # 2. Monotone in the head's pension, for every family. + all_ids = list(sim.populations["benunit"].ids) + simulated = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + + def every_family(variant): + return np.array( + [simulated[all_ids.index(name)] for name in _all_names(population, variant)] + ) + + base_ctr = every_family("base") + for variant in ["pension", "high_pension"]: + assert (every_family(variant) <= base_ctr + 1e-6).all() + + # 3. No reduction on a very high income, outside the passport and UC + # routes. + passported = family("council_tax_reduction_relevant_income_based_benefit", "base") + universal_credit = family("universal_credit", "base") > 0 + high = family("simulated_council_tax_reduction_benunit", "high_pension") + assessed_on_income = ~passported & ~(universal_credit & ~head_alone) + assert (high[assessed_on_income] == 0).all() + + # 4. Identity. + liable = sim.calculate("council_tax_reduction_liable_person", YEAR) + head = sim.calculate("council_tax_reduction_household_head", YEAR) + applicant = sim.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) + alone = sim.populations["person"].benunit( + "council_tax_reduction_head_applies_alone", YEAR + ) + assert (applicant[liable & head]).all() + assert (applicant[~alone] == claimant_or_partner[~alone]).all() + assert (applicant[alone] == (liable & head)[alone]).all() + + +def _all_names(population, variant): + """Every benefit unit of every household in one variant, in order.""" + names = [] + for h, house in enumerate(population): + names.append(f"h{h}_{variant}_family") + if house["other_family"] is not None: + names.append(f"h{h}_{variant}_other_family") + return names + + +@st.composite +def grandmother_households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + head_age=draw(st.integers(68, 100)), + head_pension=draw(money), + head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), + parent_age=draw(st.integers(16, 17)), + parent_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, 3_000])), + universal_credit=draw(st.sampled_from([None, 5_000])), + council_tax=draw(st.floats(0, 4_000, allow_nan=False)), + savings=draw(st.floats(0, 20_000, allow_nan=False)), + ) + + +def add_grandmother_household(people, benunits, homes, name, house, split): + country, local_authority = house["scheme"] + head = f"{name}_grandmother" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=house["head_pension"], + **house["head_flags"], + ) + ) + young = [] + for i, parent in enumerate(["mother", "father"]): + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=house["parent_earnings"][i], + ) + ) + young.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + young.append(f"{name}_baby") + young_benefits = { + benefit: {YEAR: house[benefit]} + for benefit in ["jsa_income", "universal_credit"] + if house[benefit] is not None + } + common = dict( + claims_all_entitled_benefits={YEAR: True}, would_claim_uc={YEAR: False} + ) + if split: + benunits[f"{name}_family"] = dict(members=[head], **common) + benunits[f"{name}_young"] = dict(members=young, **common, **young_benefits) + else: + benunits[f"{name}_family"] = dict( + members=[head] + young, **common, **young_benefits + ) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = [head] + young + + +DIFFERENTIAL_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_reduction_premiums", + "council_tax_reduction_relevant_income_based_benefit", + "simulated_council_tax_reduction_benunit", +] + + +@given(st.lists(grandmother_households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_head_applying_alone_matches_her_own_benefit_unit(population): + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for split in [False, True]: + add_grandmother_household( + people, + benunits, + homes, + f"h{h}_{'split' if split else 'merged'}", + house, + split, + ) + sim = Simulation(situation=dict(people=people, benunits=benunits, households=homes)) + merged = [f"h{h}_merged_family" for h in range(len(population))] + split = [f"h{h}_split_family" for h in range(len(population))] + assert by_benunit(sim, "council_tax_reduction_head_applies_alone", merged).all() + assert not by_benunit(sim, "council_tax_reduction_head_applies_alone", split).any() + for variable in DIFFERENTIAL_VARIABLES: + assert np.allclose( + by_benunit(sim, variable, merged), by_benunit(sim, variable, split) + ), variable + household_ids = list(sim.populations["household"].ids) + for variable in [ + "council_tax_reduction_household_has_pensioner", + "council_tax_reduction_household_has_non_dep_exemption", + "council_tax_reduction", + ]: + values = sim.calculate(variable, YEAR) + merged_values = [ + values[household_ids.index(f"h{h}_merged")] for h in range(len(population)) + ] + split_values = [ + values[household_ids.index(f"h{h}_split")] for h in range(len(population)) + ] + assert np.allclose(merged_values, split_values), variable diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 966d230ff7..f2efdff84c 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -10,6 +10,15 @@ def is_full_time_student_non_dep(person, period): ) +def applicant_has_universal_credit(benunit, period): + """Whether the Council Tax Reduction applicant has the benefit unit's + Universal Credit award. The award belongs to the benefit unit's claimant + and partner, so not to a household head who applies alone.""" + return (benunit("universal_credit", period) > 0) & ~benunit( + "council_tax_reduction_head_applies_alone", period + ) + + def legacy_council_tax_reduction( benunit, period, @@ -25,7 +34,7 @@ def legacy_council_tax_reduction( applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) uc_applicable_amount = benunit("uc_maximum_amount", period) uc_applicable_income = ( benunit("uc_earned_income", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index 8d64c045ab..1ca4fe5bd1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -5,26 +5,44 @@ class council_tax_reduction_applicable_amount(Variable): value_type = float entity = BenUnit label = "applicable Council Tax Reduction amount" + documentation = ( + "The applicant's personal allowance and premiums. Ages, couple status " + "and State Pension age are those of the Council Tax Reduction " + "applicant and partner (is_council_tax_reduction_applicant_or_partner). " + "Where the household head applies alone, the head is a single person " + "with no partner or children, whatever the benefit unit's claimant " + "and partner are." + ) definition_period = YEAR unit = GBP reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7", "https://www.legislation.gov.uk/ssi/2021/249/schedule/1", ) def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.allowances - any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) - eldest_age = benunit("eldest_claimant_or_partner_age", period) + person = benunit.members + applicant = person("is_council_tax_reduction_applicant_or_partner", period) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + any_over_SP_age = benunit.any(applicant & person("is_SP_age", period)) + eldest_age = benunit.max(where(applicant, person("age", period), -np.inf)) older_age_threshold = p.age_threshold.older younger_age_threshold = p.age_threshold.younger u_18 = eldest_age < younger_age_threshold u_25 = eldest_age < older_age_threshold o_25 = (eldest_age >= older_age_threshold) & ~any_over_SP_age o_18 = (eldest_age >= younger_age_threshold) & ~any_over_SP_age - single = benunit("is_single_person", period) - couple = benunit("is_couple", period) - lone_parent = benunit("is_lone_parent", period) + # A head applying alone has no partner, and the benefit unit's + # children are its claimant's and partner's. + single = where(head_applies_alone, True, benunit("is_single_person", period)) + couple = where(head_applies_alone, False, benunit("is_couple", period)) + lone_parent = where( + head_applies_alone, False, benunit("is_lone_parent", period) + ) single_personal_allowance = ( u_25 * p.single.younger + o_25 * p.single.older @@ -45,5 +63,5 @@ def formula(benunit, period, parameters): + couple * couple_personal_allowance + lone_parent * lone_parent_personal_allowance ) * WEEKS_IN_YEAR - premiums = benunit("benefits_premiums", period) + premiums = benunit("council_tax_reduction_premiums", period) return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 444ebecc09..ca46d9fd3b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -5,18 +5,40 @@ class council_tax_reduction_applicable_income(Variable): value_type = float entity = BenUnit label = "relevant income for Council Tax Reduction means test" + documentation = ( + "The income of the Council Tax Reduction applicant and partner " + "(is_council_tax_reduction_applicant_or_partner), with, as the model " + "did before, the income of the family's children or young persons. " + "The benefit unit's own benefits (Child Benefit, Income Support, " + "income-based Jobseeker's Allowance, income-related Employment and " + "Support Allowance, Universal Credit and tax credits) count only where " + "the applicant is the benefit unit's claimant: where the household " + "head applies alone, those awards and children belong to the benefit " + "unit's claimant and partner, not to the head." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (CTR (Prescribed Requirements) (England) - # Regs 2012 Sch 1 para 11); dropping dependants' own income is a follow-up. - # Anyone else in the benefit unit does not count. + # Members whose income counts: the applicant and partner and, as the model + # did before, the programme's own children or young persons. The regulations + # count only the applicant's and partner's (CTR (Prescribed Requirements) + # (England) Regs 2012 Sch 1 para 11; SSI 2012/319 reg 21(3) and SSI + # 2021/249 reg 36(2) exclude a child's); dropping dependants' own income is + # a follow-up. Anyone else in the benefit unit does not count. person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_young_person_for_legacy_benefits", period + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + applicant_family = ~head_applies_alone + members = person("is_council_tax_reduction_applicant_or_partner", period) | ( + person("is_child_or_young_person_for_legacy_benefits", period) + & benunit.project(applicant_family) ) benunit_means_tested_benefits = [ "child_benefit", @@ -42,14 +64,15 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - benefits = add_for_members( - benunit, period, benunit_means_tested_benefits, members + # The benefit unit's awards are its claimant's and partner's. + benefits = applicant_family * add( + benunit, period, benunit_means_tested_benefits ) income = add_for_members(benunit, period, income_components, members) personal_benefit_income = add_for_members( benunit, period, personal_benefits, members ) - credits = add_for_members(benunit, period, ["tax_credits"], members) + credits = applicant_family * add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefit_income + credits + benefits if not bi.interactions.include_in_means_tests: diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py new file mode 100644 index 0000000000..774c5b2853 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_head_applies_alone(Variable): + value_type = bool + entity = BenUnit + label = "Household head applies for Council Tax Reduction alone" + documentation = ( + "Whether this family's Council Tax Reduction applicant is its " + "household head alone, because the head is treated as liable for the " + "council tax but is not the benefit unit's claimant or partner. An " + "example is a grandmother who heads the household and shares a benefit " + "unit with a young couple and their baby: the couple are the benefit " + "unit's claimant and partner, but she is the person liable for the " + "council tax. The applicant's means test then covers the head's own " + "income and circumstances, as a single person with no partner or " + "children, since the model cannot tell whether any other member is " + "the head's partner or child. The benefit unit's means-tested " + "benefits (such as Universal Credit, Income Support and Child Benefit) " + "belong to its claimant and partner, not to the head." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(liable_head & ~claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py index 3ede96b204..d895972685 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -5,14 +5,23 @@ class council_tax_reduction_household_has_non_dep_exemption(Variable): value_type = bool entity = Household label = "CTR household has a non-dependant deduction exemption" + documentation = ( + "Whether no non-dependant deduction is made from the household head's " + "family's Council Tax Reduction because the applicant or partner " + "(is_council_tax_reduction_applicant_or_partner) is blind or receives " + "Attendance Allowance, the care component of Disability Living " + "Allowance, the daily living component of Personal Independence " + "Payment or Armed Forces Independence Payment." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8" def formula(household, period, parameters): person = household.members claimant_benunit = person.benunit("benunit_contains_household_head", period) + # SI 2012/2885 Sch 1 para 8(6): "the applicant or his partner". claimant_or_partner = claimant_benunit & person( - "is_claimant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) is_blind = person("is_blind", period) & claimant_or_partner attendance_allowance = ( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py index 555231a5ab..8d4cb9116d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py @@ -4,10 +4,25 @@ class council_tax_reduction_household_has_pensioner(Variable): value_type = bool entity = Household - label = "CTR claimant benefit unit has a pension-age member" + label = "CTR household head's family has a pension-age applicant or partner" + documentation = ( + "Whether the Council Tax Reduction applicant or partner " + "(is_council_tax_reduction_applicant_or_partner) in the household " + "head's family is over State Pension age, which stands in for the " + "qualifying age for State Pension Credit." + ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + ) def formula(household, period, parameters): person = household.members claimant_benunit = person.benunit("benunit_contains_household_head", period) - return household.any(claimant_benunit & person("is_SP_age", period)) + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + return household.any( + claimant_benunit & applicant_or_partner & person("is_SP_age", period) + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py new file mode 100644 index 0000000000..2510681121 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_premiums(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction disability and carer premiums" + documentation = ( + "The disability and carer premiums in the Council Tax Reduction " + "applicable amount, which are the applicant's. Where the applicant is " + "the benefit unit's claimant, these are the benefit unit's premiums " + "(benefits_premiums). Where the household head applies alone " + "(council_tax_reduction_head_applies_alone), they are the premiums a " + "single claimant would get on the head's own circumstances: the " + "single rates of the disability, enhanced disability, severe " + "disability and carer premiums, each on the same person-level " + "condition benefits_premiums uses." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", + ) + + def formula(benunit, period, parameters): + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + person = benunit.members + head = person( + "is_council_tax_reduction_applicant_or_partner", period + ) & benunit.project(head_applies_alone) + + def head_qualifies(condition): + return benunit.any(head & person(condition, period)) + + disability = parameters(period).gov.dwp.disability_premia + carer = parameters(period).gov.dwp.carer_premium + weekly_single_premiums = ( + head_qualifies("is_disabled_for_benefits") * disability.disability_single + + head_qualifies("is_enhanced_disabled_for_benefits") + * disability.enhanced_single + + head_qualifies("is_severely_disabled_for_benefits") + * disability.severe_single + + head_qualifies("is_carer_for_benefits") * carer.single + ) + return where( + head_applies_alone, + weekly_single_premiums * WEEKS_IN_YEAR, + benunit("benefits_premiums", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py index 74df5864f3..0fb48ca319 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -5,7 +5,19 @@ class council_tax_reduction_relevant_income_based_benefit(Variable): value_type = bool entity = BenUnit label = "CTR claimant has an income-based passporting benefit" + documentation = ( + "Whether the Council Tax Reduction applicant or partner is on Income " + "Support, income-based Jobseeker's Allowance or income-related " + "Employment and Support Allowance. These are the benefit unit's " + "awards, which belong to its claimant and partner, so they do not " + "passport a household head who applies alone " + "(council_tax_reduction_head_applies_alone)." + ) definition_period = YEAR def formula(benunit, period, parameters): - return add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + income_based_benefit = ( + add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + ) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + return income_based_benefit & ~head_applies_alone diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..a54e31eba4 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class is_council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Council Tax Reduction applicant or partner" + documentation = ( + "Whether this person is the applicant, or the applicant's partner, " + "for the Council Tax Reduction means test of their family. The " + "applicant is the person liable for the council tax " + "(council_tax_reduction_liable_person), and the means test covers the " + "applicant's own income and their partner's. " + "Where the family's liable person is the benefit unit's claimant or " + "partner, this is the benefit unit's claimant and partner, whatever " + "their ages: a partner under 18 is not liable, but their income " + "counts. Where the liable person is a household head who is not the " + "benefit unit's claimant or partner, it is the head alone (see " + "council_tax_reduction_head_applies_alone). A family with no liable " + "person cannot claim; for it this is the benefit unit's claimant and " + "partner." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/26", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(person, period, parameters): + # SSI 2012/319 reg 21(1), SI 2012/2885 Sch 1 para 11 and the Welsh + # and 2021 Scottish equivalents: the income and capital of the + # applicant and of the applicant's partner. + head_applies_alone = person.benunit( + "council_tax_reduction_head_applies_alone", period + ) + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return where(head_applies_alone, liable_head, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index 7f9b1b7663..4890a6e409 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -25,7 +26,7 @@ def formula(benunit, period, parameters): household("country", period), household("council_tax_reduction_household_has_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index abeea551dc..ad8b1daf16 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * import numpy as np +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, +) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_oxford_working_age, ) @@ -24,8 +27,7 @@ def formula(benunit, period, parameters): "council_tax_reduction_claimant_benunit", period ) would_claim = benunit("would_claim_council_tax_reduction", period) - universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) capital_eligible = capital <= ctr.means_test.capital_limit @@ -35,7 +37,11 @@ def formula(benunit, period, parameters): ) annual_income = benunit("council_tax_reduction_applicable_income", period) - child_benefit = benunit("child_benefit", period) + # Applicable income counts Child Benefit only where the applicant is + # the benefit unit's claimant, so only then is it taken off again. + child_benefit = benunit("child_benefit", period) * ~benunit( + "council_tax_reduction_head_applies_alone", period + ) weekly_income = max_(0, annual_income - child_benefit) / WEEKS_IN_YEAR weekly_income += where(has_uc_award, 0, weekly_tariff_income) relevant_income_based_benefit = benunit( diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index 3c86931aee..437ffc5295 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -23,7 +24,7 @@ def formula(benunit, period, parameters): household("country", period), household("council_tax_reduction_household_has_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) From ffa5ff531bf9e962d942051e7fb7a2fb105eaba5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:08:33 -0400 Subject: [PATCH 006/224] Reconcile reported and simulated reductions within a household MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the scheme chosen per family, one household can hold a simulated claim and a family whose scheme is not simulated. Review r1 found that a family that cannot claim then kept its reported reduction beside a head whose simulated award already covered the whole bill: £3,000 against £1,800. - A family that cannot claim keeps a reported reduction only where no claim in its household is simulated (new household flag council_tax_reduction_household_has_simulated_claim). Without sharers this is the base behaviour. - A jointly liable claimant's reported reduction is limited to its own share of the council tax (SI 2012/2885 Sch 1 para 7(3)-(4)), the most any scheme could give it. Tests: three YAML cases; the property tests now generate reported reductions, check the fallback and the household bound, and toggle one claimant's exempting benefit to show it leaves the other claim unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 107 +++++++++++++ ...ction_claimant_family_scheme_properties.py | 148 +++++++++++++++++- .../variables/gov/dwp/council_tax_benefit.py | 32 +++- ...reduction_household_has_simulated_claim.py | 21 +++ .../council_tax_reduction_scheme_supported.py | 4 +- 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index c364aad9fd..e0e30352a3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -218,6 +218,113 @@ council_tax_benefit: [300, 900] council_tax_reduction: 1_200 +- name: A family that cannot claim keeps no reported reduction beside a simulated claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + adult_child: + age: 40 + council_tax_benefit_reported: 1_200 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + child_family: + members: [adult_child] + would_claim_uc: false + households: + home: + members: [head, adult_child] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_claimant_benunit: [true, false] + # The adult child's own scheme (Maidstone's working-age one) is not + # simulated, but the household's bill is: the head's pensioner claim + # covers all of it. The child's report cannot be added on top. + council_tax_reduction_scheme_supported: [true, false] + council_tax_reduction_household_has_simulated_claim: true + council_tax_benefit: [1_800, 0] + council_tax_reduction: 1_800 + +- name: Where no claim is simulated, every family keeps its reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 300 + parent: + age: 70 + council_tax_benefit_reported: 200 + benunits: + head_family: + members: [head] + would_claim_uc: false + parent_family: + members: [parent] + would_claim_uc: false + households: + home: + members: [head, parent] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The head's working-age scheme is not modelled, so nothing in the + # household is simulated; the parent cannot claim but keeps its report. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: false + council_tax_benefit: [300, 200] + council_tax_reduction: 500 + +- name: A jointly liable claimant's reported reduction is limited to its share of the bill + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + sharer: + age: 40 + council_tax_benefit_reported: 1_500 + benunits: + head_family: + members: [head] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # SI 2012/2885 Sch 1 para 7(3)-(4): each of the two is liable for half, + # 900, so no scheme could reduce the sharer's bill by more than that. + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_scheme_supported: [true, false] + council_tax_benefit: [900, 900] + council_tax_reduction: 1_800 + - name: Newham, the head's disability exempts only the head's claim from non-dependant deductions period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index e6bac255c1..e863dcf8f1 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -27,9 +27,17 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and award unchanged. -5. Bounds: a family that cannot claim gets no simulated reduction, and the - household's reduction never exceeds its council tax. + scheme and award unchanged, reported reductions included. +5. Fallback: a claiming family gets its simulated reduction where its scheme + is simulated, and otherwise its reported one, limited to its share of the + council tax where it is jointly liable. A family that cannot claim gets + its reported reduction only where no claim in its household is simulated. +6. Bounds: a family that cannot claim gets no simulated reduction; a + household with a simulated claim never gets more than its council tax. +7. The exemption is the applicant's own: in a council's working-age scheme, + giving one claiming family an exempting benefit removes the non-dependant + deductions from its own reduction and leaves every other claim's + deductions and award unchanged. """ import numpy as np @@ -86,6 +94,7 @@ ) disability = st.sampled_from([None, *DISABILITY]) money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +reported = st.one_of(st.just(0.0), st.floats(0, 3_000, allow_nan=False)) @st.composite @@ -105,6 +114,7 @@ def family(draw, role): return dict( role=role, child_age=None if alone else draw(st.one_of(st.none(), st.integers(0, 15))), + reported=draw(reported), **draw(adults(1 if alone else 2)), ) @@ -171,6 +181,8 @@ def build(population, alone=False, perturb_others=False): disability_input = house["other_disabilities"][replacement] replacement += 1 people[pid] = person(age, disability_input, fam["earnings"][i]) + if i == 0: + people[pid]["council_tax_benefit_reported"] = fam["reported"] people[pid]["is_household_head"] = ( alone or fam["role"] == "head" ) and i == 0 @@ -268,10 +280,35 @@ def test_scheme_follows_own_family(population): assert not np.any((local > 0.005) & pensioner) assert not np.any((local > 0.005) & ~england) - # 5. Bounds. (No family has a reported reduction to fall back on here.) + # 5. Fallback. + benefit = calc(sim, "council_tax_benefit") + reported_amount = calc(sim, "council_tax_benefit_reported", map_to="benunit") + share = calc(sim, "council_tax_reduction_joint_liability_share") + # Index households directly: map_to splits a household value among members. + house = facts["house"] + bill = calc(sim, "council_tax")[house] + simulates = calc(sim, "council_tax_reduction_household_has_simulated_claim") + household_simulates = simulates[house].astype(bool) + expected = np.where( + claimant, + np.where( + supported, + simulated, + np.where( + share < 1, np.minimum(reported_amount, bill * share), reported_amount + ), + ), + np.where(household_simulates, 0, reported_amount), + ) + np.testing.assert_allclose(benefit, expected, atol=0.01) + simulated_claims = np.bincount(house, weights=claimant & supported) + assert np.array_equal(simulates.astype(bool), simulated_claims > 0) + + # 6. Bounds. assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") - assert np.all(household_reduction <= calc(sim, "council_tax") + 0.01) + over = household_reduction > calc(sim, "council_tax") + 0.01 + assert not np.any(over & simulates) @PROPERTY_SETTINGS @@ -298,3 +335,104 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + + +@st.composite +def shared_council_homes(draw): + """A working-age head and sharer under one council's scheme, with a + non-dependant in remunerative work, and an exempting benefit for one of + the two claimants.""" + return dict( + local_authority=draw(st.sampled_from(sorted(MODELLED_WORKING_AGE_SCHEMES))), + ages=draw(st.lists(st.integers(20, 60), min_size=2, max_size=2)), + non_dependant_age=draw(st.integers(20, 60)), + non_dependant_earnings=draw(st.floats(10_000, 60_000, allow_nan=False)), + council_tax=draw(st.floats(1_000, 4_000, allow_nan=False)), + exempt_family=draw(st.sampled_from([0, 1])), + disability=draw(st.sampled_from(sorted(DISABILITY))), + ) + + +def council_situation(homes, with_disability): + people, benunits, households = {}, {}, {} + for h, home in enumerate(homes): + ids = [] + for f, age in enumerate(home["ages"]): + pid = f"h{h}_claimant_{f}" + disability_input = ( + home["disability"] + if with_disability and f == home["exempt_family"] + else None + ) + people[pid] = person(age, disability_input, 0) + people[pid]["is_household_head"] = f == 0 + benunits[f"h{h}_f{f}"] = { + "members": [pid], + "liable_for_share_of_household_rent": f == 1, + "claims_all_entitled_benefits": True, + "would_claim_uc": False, + } + ids.append(pid) + pid = f"h{h}_non_dependant" + people[pid] = person( + home["non_dependant_age"], None, home["non_dependant_earnings"] + ) + people[pid].update(is_household_head=False, weekly_hours=37.5) + benunits[f"h{h}_f2"] = {"members": [pid], "would_claim_uc": False} + ids.append(pid) + households[f"h{h}"] = dict( + members=ids, + country="ENGLAND", + local_authority=home["local_authority"], + council_tax=home["council_tax"], + rent=12_000, + tenure_type="RENT_PRIVATELY", + savings=0, + ) + return { + group: { + name: { + key: value if key == "members" else {YEAR: value} + for key, value in entity.items() + } + for name, entity in entities.items() + } + for group, entities in ( + ("people", people), + ("benunits", benunits), + ("households", households), + ) + } + + +@PROPERTY_SETTINGS +@given(st.lists(shared_council_homes(), min_size=1, max_size=6)) +def test_exemption_is_the_applicants_own(homes): + without = Simulation(situation=council_situation(homes, with_disability=False)) + with_ = Simulation(situation=council_situation(homes, with_disability=True)) + # Benefit units run head, sharer, non-dependant in each household. + family = np.tile([0, 1, 2], len(homes)) + exempt_family = np.repeat([home["exempt_family"] for home in homes], 3) + exempted = family == exempt_family + other = (family < 2) & ~exempted + variables = [v + "_non_dep_deductions" for v in LOCAL_SCHEMES] + + def total(sim_, names): + return sum(calc(sim_, name) for name in names) + + before_deductions = total(without, variables) + after_deductions = total(with_, variables) + # 7. The non-dependant is deducted from both claims without the benefit, + # so the test is not vacuous. + assert np.all(before_deductions[family < 2] > 0) + flag = "council_tax_reduction_applicant_has_non_dep_exemption" + assert not np.any(calc(without, flag)[family < 2]) + assert np.array_equal(calc(with_, flag)[family < 2], exempted[family < 2]) + assert np.all(after_deductions[exempted] == 0) + np.testing.assert_allclose( + after_deductions[other], before_deductions[other], atol=1e-6 + ) + for name in LOCAL_SCHEMES + ["council_tax_benefit"]: + np.testing.assert_allclose( + calc(with_, name)[other], calc(without, name)[other], atol=1e-6 + ) diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index b80f01b1f0..bda53d9111 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -5,11 +5,41 @@ class council_tax_benefit(Variable): value_type = float entity = BenUnit label = "Council Tax Benefit" + documentation = ( + "A family that claims gets the simulated reduction where the model " + "simulates its own scheme, and its reported reduction otherwise. A " + "claimant jointly liable with others can get at most its own share " + "of the council tax, so its reported reduction is limited to that " + "share. A family that cannot claim keeps a reported reduction only " + "where no claim in its household is simulated." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/4", + ) def formula(benunit, period, parameters): supported = benunit("council_tax_reduction_scheme_supported", period) simulated = benunit("simulated_council_tax_reduction_benunit", period) reported = benunit("council_tax_benefit_reported", period) - return where(supported, simulated, reported) + claimant = benunit("council_tax_reduction_claimant_benunit", period) + share = benunit("council_tax_reduction_joint_liability_share", period) + # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's + # maximum reduction is on its share of the council tax. + share_of_liability = ( + benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + * share + ) + reported_claim = where(share < 1, min_(reported, share_of_liability), reported) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) + not_claiming = where(household_simulates, 0, reported) + return where( + claimant, where(supported, simulated, reported_claim), not_claiming + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py new file mode 100644 index 0000000000..897e43016f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_simulated_claim.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_simulated_claim(Variable): + value_type = bool + entity = Household + label = "A Council Tax Reduction claim in the household is simulated" + documentation = ( + "Whether any family that claims Council Tax Reduction for the " + "household falls under a scheme the model simulates. The simulated " + "awards then cover those families' shares of the council tax. A " + "reported reduction of a family the model does not treat as liable " + "cannot be reconciled with them, so it is not kept." + ) + definition_period = YEAR + + def formula(household, period, parameters): + person = household.members + claimant = person.benunit("council_tax_reduction_claimant_benunit", period) + supported = person.benunit("council_tax_reduction_scheme_supported", period) + return household.any(claimant & supported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index c559fe9bb9..915e814e52 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -14,8 +14,8 @@ class council_tax_reduction_scheme_supported(Variable): "the Scottish and Welsh schemes, and the working-age schemes of the " "English councils it models. The scheme follows the family's own " "pensioner status, so families in one household can fall under " - "different schemes. A family whose scheme is not simulated keeps its " - "reported reduction." + "different schemes. A claiming family whose scheme is not simulated " + "falls back to its reported reduction (council_tax_benefit)." ) definition_period = YEAR From 905d0d9c164db1f27ca5f666b2a7e8c3ea504cd7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:25:32 -0400 Subject: [PATCH 007/224] Changelog for the CTR applicant means test Co-Authored-By: Claude Opus 5.5 --- changelog.d/ctr-applicant-means-test.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/ctr-applicant-means-test.fixed.md diff --git a/changelog.d/ctr-applicant-means-test.fixed.md b/changelog.d/ctr-applicant-means-test.fixed.md new file mode 100644 index 0000000000..010dc6840c --- /dev/null +++ b/changelog.d/ctr-applicant-means-test.fixed.md @@ -0,0 +1 @@ +Means-test the Council Tax Reduction applicant and their partner rather than the benefit unit's claimant and partner, so a household head who is liable for the council tax but shares a benefit unit with a young couple is assessed on her own income, allowance, premiums and exemption. From 7ac22effc3c459e74abbbdf84de973a6c1f93dd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:26:43 -0400 Subject: [PATCH 008/224] Set a claim's scheme and exemption from the applicant and their partner #2009 now names the liable people directly. A household head can be liable without being the claimant or partner of their family (a grandparent heading a family formed around a young couple), so "the applicant or his partner" (SI 2012/2885 reg 3, Sch 1 para 8(6)) is not always the family's claimant and partner. New person-level council_tax_reduction_applicant_or_partner: the liable member, and the other member of the couple where the liable member is the claimant or partner, whatever the partner's age; in a family that cannot claim, its claimant and partner. The pensioner flag and the applicant exemption read it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 78 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 5 +- ...duction_applicant_has_non_dep_exemption.py | 6 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 +++++++++ .../council_tax_reduction_pensioner.py | 14 ++-- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index e0e30352a3..98c9a2afbe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -566,3 +566,81 @@ council_tax_reduction_applicable_amount: [4_968.60, 13_312] simulated_council_tax_reduction_benunit: [293.72, 562.40] council_tax_reduction: 856.12 + +- name: A pension-age household head applies as a pensioner although her family's claimant and partner are young parents + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + pip_dl: 3_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The grandmother is the liable resident, so she is the applicant; the + # young parents are the family's claimant and partner but not hers. + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_applicant_or_partner: [true, false, false, false] + # Her age puts the claim on the pensioner scheme (SI 2012/2885 reg 3). + council_tax_reduction_pensioner: [true] + council_tax_reduction_scheme_supported: [true] + # The young mother's PIP is not the applicant's or her partner's + # (Sch 1 para 8(6)). + council_tax_reduction_applicant_has_non_dep_exemption: [false] + simulated_council_tax_reduction_benunit: [1_800] + +- name: An applicant's partner under 18 still counts for the applicant's exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 17 + is_household_head: false + is_claimant_or_partner: true + pip_dl: 3_000 + benunits: + couple: + members: [head, partner] + would_claim_uc: false + households: + home: + members: [head, partner] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Only the head is liable (LGFA 1992 s.6(5)), but para 8(6) asks about + # "the applicant or his partner". + council_tax_reduction_liable_person: [true, false] + council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicant_has_non_dep_exemption: [true] + council_tax_reduction_pensioner: [false] diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index e863dcf8f1..70c5e88af2 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -11,7 +11,10 @@ 2021/249 reg 90(6); SSI 2012/319 reg 48(6)). Invariants, for any generated population of households with a flagged head, -up to two sharer families and at most one non-dependant family: +up to two sharer families and at most one non-dependant family. Every adult +here is 20 or over and the head is a claimant, so each family's applicant and +partner are its claimant and partner (the YAML cases cover a head who is +not): 1. Own members: a family is a pensioner exactly when its claimant or partner has reached State Pension age. It is exempt from non-dependant deductions diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index 4a539258a2..f49ffa5ff2 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -32,7 +32,9 @@ def formula(benunit, period, parameters): # applicant or his partner is" blind or receiving one of the listed # benefits "in respect of himself". person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) exempting = ( person("is_blind", period) | (person("attendance_allowance", period) > 0) @@ -40,4 +42,4 @@ def formula(benunit, period, parameters): | (person("dla_sc", period) > 0) | (person("armed_forces_independence_payment", period) > 0) ) - return benunit.any(claimant_or_partner & exempting) + return benunit.any(applicant_or_partner & exempting) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..2fbadd516f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Applicant for Council Tax Reduction, or the applicant's partner" + documentation = ( + "The people whose circumstances set a family's Council Tax Reduction " + "scheme and its exemption from non-dependant deductions: the " + "applicant and the applicant's partner. In a family that claims, the " + "applicant is a member the model treats as liable for the council " + "tax, and the partner is the other member of the couple where the " + "applicant is the family's claimant or partner, whatever the " + "partner's age. A household head who is not the claimant or partner " + "of their family (a grandparent heading a family formed around a " + "young couple, say) applies alone. In a family that cannot claim, " + "these are the claimant and partner, who would apply if it could." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", + ) + + def formula(person, period, parameters): + liable = person("council_tax_reduction_liable_person", period) + claimant_or_partner = person("is_claimant_or_partner", period) + family_claims = person.benunit.any(liable) + # The couple, where one of them is liable; a partner under 18 is not + # liable but is still the applicant's partner. + couple_applies = person.benunit.any(liable & claimant_or_partner) + applicant_or_partner = liable | (couple_applies & claimant_or_partner) + return where(family_claims, applicant_or_partner, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index d958b9f680..3305026f80 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -7,12 +7,12 @@ class council_tax_reduction_pensioner(Variable): label = "Pensioner for Council Tax Reduction" documentation = ( "Whether this family's claim falls under the pension-age Council Tax " - "Reduction rules: the claimant or partner has reached State Pension " - "age. Each applicant's scheme follows their own family, so where " - "families share the rent and each claims, a working-age family is " + "Reduction rules: the applicant or the applicant's partner has reached " + "State Pension age. Each applicant's scheme follows their own family, " + "so where families share the rent and each claims, a working-age family is " "assessed under the working-age rules even if the household head's " "family is pension-age, and the reverse. The further condition that " - "neither the claimant nor a partner is on Income Support, income-based " + "neither the applicant nor a partner is on Income Support, income-based " "Jobseeker's Allowance, income-related Employment and Support " "Allowance or Universal Credit is not modelled here." ) @@ -30,5 +30,7 @@ def formula(benunit, period, parameters): # state pension credit". is_SP_age stands in for that age, as # elsewhere in the model. person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - return benunit.any(claimant_or_partner & person("is_SP_age", period)) + applicant_or_partner = person( + "council_tax_reduction_applicant_or_partner", period + ) + return benunit.any(applicant_or_partner & person("is_SP_age", period)) From 6d3f11f2260773787ab8209ff1ca3a5a8bdd6c5f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:35:37 -0400 Subject: [PATCH 009/224] Widen the CTR applicant differential to working-age heads and the local schemes Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 5 ++++- ...ncil_tax_reduction_applicant_properties.py | 20 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index bf25b60348..8fc7284064 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -290,6 +290,7 @@ age: 19 is_parent: true is_household_head: false + employment_income: 30_000 baby: age: 0 is_household_head: false @@ -308,10 +309,12 @@ savings: 0 output: council_tax_reduction_head_applies_alone: [true] + # The young father's earnings and the couple's award are not hers. council_tax_reduction_applicable_income: [0] # Single aged 25 or over: 95.55 * 52. council_tax_reduction_applicable_amount: [4_968.60] - # No excess income: 1,800 * 70%. + # No excess income: 1,800 * 70%. On the Universal Credit route the + # couple's earnings and award would leave nothing. newham_council_tax_reduction: [1_260] - name: A partner under 18 is not liable, but their income counts in the couple's means test diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 895d3b2607..dcbd9f3e00 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -30,12 +30,16 @@ family. Where the head does not apply alone, the applicant and partner are exactly the benefit unit's claimant and partner (ordinary families are unchanged). Where the head applies alone, the head is the only one. -5. Differential: a head over State Pension age sharing a benefit unit with +5. Differential: a head of any age from 18 sharing a benefit unit with parents aged 16 or 17 and their baby has the same applicable income, - applicable amount, premiums, scheme, exemption and simulated reduction as - the same head entered as her own benefit unit, with the young family as - another. The two inputs describe the same people, so the law gives the - same answer. + applicable amount, premiums, passport, scheme, exemption and simulated + reduction as the same head entered as her own benefit unit, with the young + family as another. The two inputs describe the same people, so the law + gives the same answer. Two limits keep the inputs comparable: no one + claims Universal Credit or Income Support by take-up (a head inside + another couple's benefit unit cannot be given her own award), and the + parents are under 18 (older ones would be her non-dependants, which the + model charges only when they are in another benefit unit). """ import numpy as np @@ -272,7 +276,7 @@ def _all_names(population, variant): def grandmother_households(draw): return dict( scheme=draw(st.sampled_from(SCHEMES)), - head_age=draw(st.integers(68, 100)), + head_age=draw(head_age), head_pension=draw(money), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), parent_age=draw(st.integers(16, 17)), @@ -314,7 +318,9 @@ def add_grandmother_household(people, benunits, homes, name, house, split): if house[benefit] is not None } common = dict( - claims_all_entitled_benefits={YEAR: True}, would_claim_uc={YEAR: False} + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + would_claim_IS={YEAR: False}, ) if split: benunits[f"{name}_family"] = dict(members=[head], **common) From ab4022bf3d4b26d0e7379b82b9fca7f3e1e67f96 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 15:31:28 -0400 Subject: [PATCH 010/224] 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 ae79d747e1e0f9084bce83c8f72e6c70361d428a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:55:41 -0400 Subject: [PATCH 011/224] Weight the CTR applicant properties to part-withdrawn awards; pin Kingston's tariff income Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 41 +++++++++++++++++++ ...ncil_tax_reduction_applicant_properties.py | 17 +++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index 8fc7284064..55b15004e8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -441,3 +441,44 @@ # A working-age couple aged 18 or over: 150.15 * 52, not the pension-age # couple rate. council_tax_reduction_applicable_amount: [7_807.80] + +- name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + private_pension_income: 5_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 10_000 + output: + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_applicable_income: [5_000] + council_tax_reduction_applicable_amount: [4_968.60] + # Tariff income ceil((10,000 - 6,000) / 250) = £16 a week, 832 a year, + # since she has no award. 1,800 - (5,000 + 832 - 4,968.60) * 20%. + kingston_upon_thames_council_tax_reduction: [1_627.32] diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index dcbd9f3e00..dec9ccab80 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -73,6 +73,11 @@ "is_blind", ] money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +# Incomes, bills and savings around the means-test thresholds, so awards are +# often part-withdrawn rather than nil or full in both representations. +pension = st.one_of(st.sampled_from([0, 5_000, 8_000, 12_000, 20_000]), money) +council_tax = st.one_of(st.just(1_800), st.floats(0, 4_000, allow_nan=False)) +savings = st.one_of(st.just(0), st.floats(0, 20_000, allow_nan=False)) head_age = st.one_of( st.sampled_from([18, 25, 40, 60, 66, 68, 80]), st.integers(18, 100) ) @@ -86,7 +91,7 @@ def households(draw): scheme=draw(st.sampled_from(SCHEMES)), shape=draw(st.sampled_from(SHAPES)), head_age=draw(head_age), - head_pension=draw(money), + head_pension=draw(pension), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), partner_age=draw(st.integers(16, 100)), parent_age=draw(st.integers(16, 30)), @@ -95,8 +100,8 @@ def households(draw): universal_credit=draw(st.sampled_from([None, None, 5_000])), other_family=draw(st.sampled_from([None, "non_dependant", "sharer"])), other_age=draw(st.integers(16, 90)), - council_tax=draw(st.floats(0, 4_000, allow_nan=False)), - savings=draw(st.floats(0, 20_000, allow_nan=False)), + council_tax=draw(council_tax), + savings=draw(savings), ) @@ -277,14 +282,14 @@ def grandmother_households(draw): return dict( scheme=draw(st.sampled_from(SCHEMES)), head_age=draw(head_age), - head_pension=draw(money), + head_pension=draw(pension), head_flags=draw(st.fixed_dictionaries({f: st.booleans() for f in FLAGS})), parent_age=draw(st.integers(16, 17)), parent_earnings=draw(st.lists(money, min_size=2, max_size=2)), jsa_income=draw(st.sampled_from([None, 3_000])), universal_credit=draw(st.sampled_from([None, 5_000])), - council_tax=draw(st.floats(0, 4_000, allow_nan=False)), - savings=draw(st.floats(0, 20_000, allow_nan=False)), + council_tax=draw(council_tax), + savings=draw(savings), ) From 3d4f12b24f6ed0d421ed940551e66d424d15db51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:06:22 -0400 Subject: [PATCH 012/224] 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 9efdea22d09f3e69f619872f6382cfbf7635bc8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:20:49 -0400 Subject: [PATCH 013/224] Use #2078's CTR applicant-or-partner variable PolicyEngine/policyengine-uk#2078 (also stacked on #2009) adds is_council_tax_reduction_applicant_or_partner and council_tax_reduction_head_applies_alone for the CTR means test. They define the same people as this PR's council_tax_reduction_applicant_or_partner: the claimant and partner where the liable person is one of them, the liable head alone otherwise, and the claimant and partner in a family that cannot claim. Take #2078's two files byte for byte, so whichever PR lands second merges without conflict, and point the pensioner flag and the applicant exemption at it. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 4 +- ...duction_applicant_has_non_dep_exemption.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 37 --------------- ...ouncil_tax_reduction_head_applies_alone.py | 38 +++++++++++++++ .../council_tax_reduction_pensioner.py | 2 +- ...ncil_tax_reduction_applicant_or_partner.py | 46 +++++++++++++++++++ 6 files changed, 88 insertions(+), 41 deletions(-) delete mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 98c9a2afbe..58c3fc2d7f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -603,7 +603,7 @@ # The grandmother is the liable resident, so she is the applicant; the # young parents are the family's claimant and partner but not hers. is_claimant_or_partner: [false, true, true, false] - council_tax_reduction_applicant_or_partner: [true, false, false, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] # Her age puts the claim on the pensioner scheme (SI 2012/2885 reg 3). council_tax_reduction_pensioner: [true] council_tax_reduction_scheme_supported: [true] @@ -641,6 +641,6 @@ # Only the head is liable (LGFA 1992 s.6(5)), but para 8(6) asks about # "the applicant or his partner". council_tax_reduction_liable_person: [true, false] - council_tax_reduction_applicant_or_partner: [true, true] + is_council_tax_reduction_applicant_or_partner: [true, true] council_tax_reduction_applicant_has_non_dep_exemption: [true] council_tax_reduction_pensioner: [false] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index f49ffa5ff2..53c4079bea 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -33,7 +33,7 @@ def formula(benunit, period, parameters): # benefits "in respect of himself". person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) exempting = ( person("is_blind", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py deleted file mode 100644 index 2fbadd516f..0000000000 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_or_partner.py +++ /dev/null @@ -1,37 +0,0 @@ -from policyengine_uk.model_api import * - - -class council_tax_reduction_applicant_or_partner(Variable): - value_type = bool - entity = Person - label = "Applicant for Council Tax Reduction, or the applicant's partner" - documentation = ( - "The people whose circumstances set a family's Council Tax Reduction " - "scheme and its exemption from non-dependant deductions: the " - "applicant and the applicant's partner. In a family that claims, the " - "applicant is a member the model treats as liable for the council " - "tax, and the partner is the other member of the couple where the " - "applicant is the family's claimant or partner, whatever the " - "partner's age. A household head who is not the claimant or partner " - "of their family (a grandparent heading a family formed around a " - "young couple, say) applies alone. In a family that cannot claim, " - "these are the claimant and partner, who would apply if it could." - ) - definition_period = YEAR - reference = ( - "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", - "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", - "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", - "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", - "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", - ) - - def formula(person, period, parameters): - liable = person("council_tax_reduction_liable_person", period) - claimant_or_partner = person("is_claimant_or_partner", period) - family_claims = person.benunit.any(liable) - # The couple, where one of them is liable; a partner under 18 is not - # liable but is still the applicant's partner. - couple_applies = person.benunit.any(liable & claimant_or_partner) - applicant_or_partner = liable | (couple_applies & claimant_or_partner) - return where(family_claims, applicant_or_partner, claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py new file mode 100644 index 0000000000..774c5b2853 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_head_applies_alone.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_head_applies_alone(Variable): + value_type = bool + entity = BenUnit + label = "Household head applies for Council Tax Reduction alone" + documentation = ( + "Whether this family's Council Tax Reduction applicant is its " + "household head alone, because the head is treated as liable for the " + "council tax but is not the benefit unit's claimant or partner. An " + "example is a grandmother who heads the household and shares a benefit " + "unit with a young couple and their baby: the couple are the benefit " + "unit's claimant and partner, but she is the person liable for the " + "council tax. The applicant's means test then covers the head's own " + "income and circumstances, as a single person with no partner or " + "children, since the model cannot tell whether any other member is " + "the head's partner or child. The benefit unit's means-tested " + "benefits (such as Universal Credit, Income Support and Child Benefit) " + "belong to its claimant and partner, not to the head." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.any(liable_head & ~claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 3305026f80..e3dc861173 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -31,6 +31,6 @@ def formula(benunit, period, parameters): # elsewhere in the model. person = benunit.members applicant_or_partner = person( - "council_tax_reduction_applicant_or_partner", period + "is_council_tax_reduction_applicant_or_partner", period ) return benunit.any(applicant_or_partner & person("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py new file mode 100644 index 0000000000..a54e31eba4 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_council_tax_reduction_applicant_or_partner.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class is_council_tax_reduction_applicant_or_partner(Variable): + value_type = bool + entity = Person + label = "Council Tax Reduction applicant or partner" + documentation = ( + "Whether this person is the applicant, or the applicant's partner, " + "for the Council Tax Reduction means test of their family. The " + "applicant is the person liable for the council tax " + "(council_tax_reduction_liable_person), and the means test covers the " + "applicant's own income and their partner's. " + "Where the family's liable person is the benefit unit's claimant or " + "partner, this is the benefit unit's claimant and partner, whatever " + "their ages: a partner under 18 is not liable, but their income " + "counts. Where the liable person is a household head who is not the " + "benefit unit's claimant or partner, it is the head alone (see " + "council_tax_reduction_head_applies_alone). A family with no liable " + "person cannot claim; for it this is the benefit unit's claimant and " + "partner." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/26", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + ) + + def formula(person, period, parameters): + # SSI 2012/319 reg 21(1), SI 2012/2885 Sch 1 para 11 and the Welsh + # and 2021 Scottish equivalents: the income and capital of the + # applicant and of the applicant's partner. + head_applies_alone = person.benunit( + "council_tax_reduction_head_applies_alone", period + ) + liable_head = person("council_tax_reduction_liable_person", period) & person( + "council_tax_reduction_household_head", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return where(head_applies_alone, liable_head, claimant_or_partner) From 35ca533b06cd9eb601e9b428fd65998d174214db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:51:09 -0400 Subject: [PATCH 014/224] Cap a reported reduction at the claimant's share only beside a simulated claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 found that capping every jointly liable claimant's reported reduction at its share could discard a lawful report: the model's divisor (#2006's joint-liability share) counts students that SI 2012/2885 Sch 1 para 7(5) leaves out. The cap exists to stop a reported claim and a simulated claim together exceeding the bill, so it now applies only where another claim in the household is simulated, on the share that claim uses. Where nothing is simulated, reports are kept as reported, as on the base. Tests: - YAML: a jointly liable claimant keeps its whole report where nothing is simulated; a pension-age grandmother in working-age applicants' family does not make their claim a pensioner's (an intended change under reg 3(1): the base paid £1,800 on the pensioner scheme). - Properties: the fallback oracle follows the narrowed cap, and property 4 compares council_tax_benefit wherever the household's reconciliation is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 83 ++++++++++++++++++- ...ction_claimant_family_scheme_properties.py | 32 +++++-- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++-- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 58c3fc2d7f..4a33287385 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -288,7 +288,7 @@ council_tax_benefit: [300, 200] council_tax_reduction: 500 -- name: A jointly liable claimant's reported reduction is limited to its share of the bill +- name: Beside a simulated claim, a jointly liable claimant's reported reduction is limited to its share of the bill period: 2026 absolute_error_margin: 0.01 input: @@ -644,3 +644,84 @@ is_council_tax_reduction_applicant_or_partner: [true, true] council_tax_reduction_applicant_has_non_dep_exemption: [true] council_tax_reduction_pensioner: [false] + +- name: Where no claim is simulated, a jointly liable claimant keeps its whole reported reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_200 + sharer: + age: 35 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_household_has_simulated_claim: false + # Nothing is simulated to reconcile with, so the report stands as on the + # base. The model's share is not used here: it does not yet leave out the + # students that SI 2012/2885 Sch 1 para 7(5) excludes from the divisor. + council_tax_benefit: [1_200, 0] + council_tax_reduction: 1_200 + +- name: A pension-age grandmother in the applicants' family does not make their claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 40 + is_household_head: true + is_parent: true + is_claimant_or_partner: true + council_tax_benefit_reported: 400 + father: + age: 42 + is_household_head: false + is_parent: true + is_claimant_or_partner: true + grandmother: + age: 80 + is_household_head: false + is_claimant_or_partner: false + benunits: + family: + members: [mother, father, grandmother] + would_claim_uc: false + council_tax_reduction_applicable_income: 0 + households: + home: + members: [mother, father, grandmother] + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # Intended change: the base put the claim on the pensioner scheme because + # any member of the head's family was over State Pension age (£1,800 + # simulated). A "pensioner" is the applicant or partner (SI 2012/2885 + # reg 3(1)), here the working-age parents, so the claim falls under + # Maidstone's unmodelled working-age scheme and keeps its report. + is_council_tax_reduction_applicant_or_partner: [true, true, false] + council_tax_reduction_pensioner: [false] + council_tax_reduction_scheme_supported: [false] + council_tax_benefit: [400] + council_tax_reduction: 400 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 70c5e88af2..7f5667b348 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -30,11 +30,15 @@ pensioner scheme, and only a working-age family by a council's scheme. 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, - scheme and award unchanged, reported reductions included. + scheme and simulated award unchanged. Its council_tax_benefit is unchanged + too, unless it falls back to a reported reduction and whether another + claim in its household is simulated changes (property 5's reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one, limited to its share of the - council tax where it is jointly liable. A family that cannot claim gets - its reported reduction only where no claim in its household is simulated. + is simulated, and otherwise its reported one. Beside a simulated claim in + its household, a jointly liable claimant's reported reduction is limited + to its share of the council tax; otherwise it is kept as reported. A + family that cannot claim gets its reported reduction only where no claim + in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a household with a simulated claim never gets more than its council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, @@ -298,7 +302,9 @@ def test_scheme_follows_own_family(population): supported, simulated, np.where( - share < 1, np.minimum(reported_amount, bill * share), reported_amount + household_simulates & (share < 1), + np.minimum(reported_amount, bill * share), + reported_amount, ), ), np.where(household_simulates, 0, reported_amount), @@ -330,7 +336,6 @@ def test_other_families_do_not_change_a_claim(population): for variable in ( "council_tax_reduction_joint_liability_share", "simulated_council_tax_reduction_benunit", - "council_tax_benefit", ): np.testing.assert_allclose( calc(before, variable)[target], @@ -338,6 +343,21 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) + # A reported fallback is reconciled against the household's simulated + # claims (property 5); compare it wherever that reconciliation is the same. + house = facts["house"] + simulates = "council_tax_reduction_household_has_simulated_claim" + same_reconciliation = ( + calc(before, simulates)[house] == calc(after, simulates)[house] + ) + supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + compare = target & (supported | same_reconciliation) + np.testing.assert_allclose( + calc(before, "council_tax_benefit")[compare], + calc(after, "council_tax_benefit")[compare], + atol=1e-6, + err_msg="council_tax_benefit", + ) @st.composite diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index bda53d9111..be06b98814 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -7,10 +7,13 @@ class council_tax_benefit(Variable): label = "Council Tax Benefit" documentation = ( "A family that claims gets the simulated reduction where the model " - "simulates its own scheme, and its reported reduction otherwise. A " - "claimant jointly liable with others can get at most its own share " - "of the council tax, so its reported reduction is limited to that " - "share. A family that cannot claim keeps a reported reduction only " + "simulates its own scheme, and its reported reduction otherwise. " + "Where another claim in the household is simulated on its share of " + "the council tax, a jointly liable claimant's reported reduction is " + "limited to its own share, so the two cannot together exceed the " + "bill; the share is the one the simulated claims use. Where no claim " + "in the household is simulated, reported reductions are kept as " + "reported. A family that cannot claim keeps a reported reduction only " "where no claim in its household is simulated." ) definition_period = YEAR @@ -27,17 +30,24 @@ def formula(benunit, period, parameters): reported = benunit("council_tax_benefit_reported", period) claimant = benunit("council_tax_reduction_claimant_benunit", period) share = benunit("council_tax_reduction_joint_liability_share", period) + household_simulates = benunit.household( + "council_tax_reduction_household_has_simulated_claim", period + ) # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's - # maximum reduction is on its share of the council tax. + # maximum reduction is on its share of the council tax. Applied only + # beside a simulated claim, which uses the same share; reports are + # otherwise kept, since the share does not yet exclude the students + # para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) * share ) - reported_claim = where(share < 1, min_(reported, share_of_liability), reported) - household_simulates = benunit.household( - "council_tax_reduction_household_has_simulated_claim", period + reported_claim = where( + household_simulates & (share < 1), + min_(reported, share_of_liability), + reported, ) not_claiming = where(household_simulates, 0, reported) return where( From cc4d9aeb952cbda653d46a513d460b3ad78b1dd2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 08:01:51 -0400 Subject: [PATCH 015/224] Apply the reported-reduction cap only beside a simulated claim that pays On the payer build, the only remaining change came from heads whose reported reduction was capped beside a sharer whose simulated claim paid nothing. With no simulated reduction competing for the bill there is nothing to reconcile, so the cap now applies only where the simulated claims in the household pay something. The household bound still holds: each simulated award is within its share, and a capped report within its own. Tests: a YAML case where the simulated claim beside a report pays nothing; the fallback oracle, the bound and property 4's comparison follow the paid condition. Co-Authored-By: Claude Opus 5.5 --- .../claimant_family_scheme.yaml | 39 +++++++++++++++++++ ...ction_claimant_family_scheme_properties.py | 37 +++++++++++------- .../variables/gov/dwp/council_tax_benefit.py | 26 ++++++++----- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 4a33287385..0bafe29a6b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -725,3 +725,42 @@ council_tax_reduction_scheme_supported: [false] council_tax_benefit: [400] council_tax_reduction: 400 + +- name: A jointly liable claimant keeps its whole report where the simulated claim beside it pays nothing + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + council_tax_benefit_reported: 1_500 + sharer: + age: 70 + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + # Too much income for any reduction. + council_tax_reduction_applicable_income: 40_000 + households: + home: + members: [head, sharer] + rent: 18_000 + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + savings: 0 + country: ENGLAND + local_authority: MAIDSTONE + output: + # The sharer's pensioner claim is simulated but pays nothing, so no + # simulated reduction competes for the bill and the head's report stands. + council_tax_reduction_scheme_supported: [false, true] + council_tax_reduction_household_has_simulated_claim: true + simulated_council_tax_reduction_benunit: [0, 0] + council_tax_benefit: [1_500, 0] + council_tax_reduction: 1_500 diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py index 7f5667b348..e18d192f40 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_family_scheme_properties.py @@ -31,16 +31,18 @@ 4. Others do not matter: changing the ages and disability benefits of every other family that claims leaves a family's pensioner status, exemption, scheme and simulated award unchanged. Its council_tax_benefit is unchanged - too, unless it falls back to a reported reduction and whether another - claim in its household is simulated changes (property 5's reconciliation). + too, unless it falls back to a reported reduction and whether a simulated + claim in its household pays something changes (property 5's + reconciliation). 5. Fallback: a claiming family gets its simulated reduction where its scheme - is simulated, and otherwise its reported one. Beside a simulated claim in - its household, a jointly liable claimant's reported reduction is limited - to its share of the council tax; otherwise it is kept as reported. A + is simulated, and otherwise its reported one. Beside a simulated claim + that pays something, a jointly liable claimant's reported reduction is + limited to its share of the council tax; otherwise it is kept as reported. A family that cannot claim gets its reported reduction only where no claim in its household is simulated. 6. Bounds: a family that cannot claim gets no simulated reduction; a - household with a simulated claim never gets more than its council tax. + household whose simulated claims pay something never gets more than its + council tax. 7. The exemption is the applicant's own: in a council's working-age scheme, giving one claiming family an exempting benefit removes the non-dependant deductions from its own reduction and leaves every other claim's @@ -296,13 +298,15 @@ def test_scheme_follows_own_family(population): bill = calc(sim, "council_tax")[house] simulates = calc(sim, "council_tax_reduction_household_has_simulated_claim") household_simulates = simulates[house].astype(bool) + paid = np.bincount(house, weights=(claimant & supported) * simulated) + household_pays = paid[house] > 0 expected = np.where( claimant, np.where( supported, simulated, np.where( - household_simulates & (share < 1), + household_pays & (share < 1), np.minimum(reported_amount, bill * share), reported_amount, ), @@ -317,7 +321,7 @@ def test_scheme_follows_own_family(population): assert np.all(simulated[~claimant] == 0) household_reduction = calc(sim, "council_tax_reduction") over = household_reduction > calc(sim, "council_tax") + 0.01 - assert not np.any(over & simulates) + assert not np.any(over & (paid > 0)) @PROPERTY_SETTINGS @@ -343,14 +347,19 @@ def test_other_families_do_not_change_a_claim(population): atol=1e-6, err_msg=variable, ) - # A reported fallback is reconciled against the household's simulated - # claims (property 5); compare it wherever that reconciliation is the same. + # A reported fallback is reconciled against the household's paid + # simulated claims (property 5); compare it wherever that is the same. house = facts["house"] - simulates = "council_tax_reduction_household_has_simulated_claim" - same_reconciliation = ( - calc(before, simulates)[house] == calc(after, simulates)[house] - ) supported = calc(before, "council_tax_reduction_scheme_supported").astype(bool) + + def household_pays(sim_): + claims = calc(sim_, "council_tax_reduction_claimant_benunit").astype(bool) + supported_ = calc(sim_, "council_tax_reduction_scheme_supported").astype(bool) + simulated = calc(sim_, "simulated_council_tax_reduction_benunit") + paid = np.bincount(house, weights=(claims & supported_) * simulated) + return paid[house] > 0 + + same_reconciliation = household_pays(before) == household_pays(after) compare = target & (supported | same_reconciliation) np.testing.assert_allclose( calc(before, "council_tax_benefit")[compare], diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index be06b98814..649231b466 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -8,11 +8,11 @@ class council_tax_benefit(Variable): documentation = ( "A family that claims gets the simulated reduction where the model " "simulates its own scheme, and its reported reduction otherwise. " - "Where another claim in the household is simulated on its share of " - "the council tax, a jointly liable claimant's reported reduction is " - "limited to its own share, so the two cannot together exceed the " - "bill; the share is the one the simulated claims use. Where no claim " - "in the household is simulated, reported reductions are kept as " + "Where another claim in the household is simulated and pays a " + "reduction on its share of the council tax, a jointly liable " + "claimant's reported reduction is limited to its own share, so the " + "two cannot together exceed the bill; the share is the one the " + "simulated claims use. Otherwise reported reductions are kept as " "reported. A family that cannot claim keeps a reported reduction only " "where no claim in its household is simulated." ) @@ -33,11 +33,19 @@ def formula(benunit, period, parameters): household_simulates = benunit.household( "council_tax_reduction_household_has_simulated_claim", period ) + # Simulated reductions paid to claims in the household. + person = benunit.members + simulated_claim = (supported & claimant) * simulated + paid_in_household = benunit.max( + person.household.sum( + person("is_benunit_head", period) * benunit.project(simulated_claim) + ) + ) # SI 2012/2885 Sch 1 para 7(3)-(4): a jointly liable claimant's # maximum reduction is on its share of the council tax. Applied only - # beside a simulated claim, which uses the same share; reports are - # otherwise kept, since the share does not yet exclude the students - # para 7(5) leaves out. + # beside a simulated claim that pays something, which uses the same + # share; reports are otherwise kept, since the share does not yet + # exclude the students para 7(5) leaves out. share_of_liability = ( benunit.household( "council_tax_reduction_maximum_eligible_liability", period @@ -45,7 +53,7 @@ def formula(benunit, period, parameters): * share ) reported_claim = where( - household_simulates & (share < 1), + (paid_in_household > 0) & (share < 1), min_(reported, share_of_liability), reported, ) From cfa65f384d61695c2877557e03cf347d0665fb5f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 17:04:22 -0400 Subject: [PATCH 016/224] Keep main's pension-age tests on the higher rate; update the reported household MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's new Pension Credit carer-income and England CTR tests use pensioners aged 70 in 2026, who reached State Pension age after 1 April 2021 and so get the lower allowance under this PR. Their arithmetic assumes the higher rate, so they now use pensioners aged 72 (State Pension age reached in 2019). The reported household's £47.68 relied on a £365.20 income disregard that main's Housing Benefit earnings-disregard fix (#1908) removed: with no earnings there is no disregard. Its HB at the lower rate is now nil; a council-tenant case with £12,000 rent shows the 608.40 gap. Co-Authored-By: Claude Opus 5.5 --- ...enefit_pension_age_personal_allowance.yaml | 31 ++++++++++++++++++- .../pension_credit_carer_income.yaml | 26 ++++++++++------ .../council_tax_reduction_pension_credit.yaml | 16 +++++++--- ...eduction_pensioner_earnings_disregard.yaml | 4 ++- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml index 3a3cacfff9..457c2e0e45 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -260,6 +260,10 @@ housing_benefit_attained_pension_age_before_cutoff: true - name: Private renter aged 70 with a £30,000 State Pension is assessed at the lower rate + # The reported household. Income is 30,000 less 3,486 income tax; with no + # earnings there is no earnings disregard. At the lower rate, + # 9,000 - 0.65 x (26,514 - 238 x 52) is below zero. The higher rate would + # give 9,000 - 0.65 x (26,514 - 256 x 52) = 418.70. period: 2026 absolute_error_margin: 0.01 input: @@ -278,4 +282,29 @@ output: housing_benefit_attained_pension_age_before_cutoff: false housing_benefit_applicable_amount: 238 * 52 - housing_benefit: 47.68 # 656.08 at the higher rate: 0.65 x (256 - 238) x 52 = 608.40 more + housing_benefit_applicable_income: 26_514 + housing_benefit: 0 + +- name: A council tenant aged 70 with the same income and £12,000 rent gets 608.40 a year less than at the higher rate + # A council tenancy, so the Local Housing Allowance does not cap the rent. + # 12,000 - 0.65 x (26,514 - 238 x 52) = 2,810.30; at the higher rate it would + # be 3,418.70, so the lower rate costs 0.65 x (256 - 238) x 52 = 608.40. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 30_000 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + rent: 12_000 + tenure_type: RENT_FROM_COUNCIL + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 + housing_benefit: 2_810.30 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml index 15ac062765..d8da059e6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml @@ -159,6 +159,8 @@ savings_credit: 0.6 * (238 - 208.07) * 52 - name: Housing Benefit and Council Tax Reduction no longer fall where Guarantee Credit used to end for a couple whose partner receives Carer's Allowance + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # Before #1952, Pension Credit left out the partner's Carer's Allowance, so # Guarantee Credit ran on to a private pension of 9,700 and the Housing # Benefit and Council Tax Reduction passports ended at 9,800, when both @@ -171,19 +173,19 @@ input: people: claimant_1: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 9_700 carer_1: - age: 70 + age: 72 state_pension: 0 care_hours: 35 claimant_2: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 9_800 carer_2: - age: 70 + age: 72 state_pension: 0 care_hours: 35 benunits: @@ -225,6 +227,8 @@ council_tax_benefit: [1_323.32, 1_307.32] - name: Net income rises where Guarantee Credit now ends for a couple whose partner receives Carer's Allowance + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # Guarantee Credit ends at a private pension of about 4,164 (income 21,392.80 # = the minimum guarantee). Across 4,100 to 4,200 net income rises by # 100 - 20 tax - 51.40 Guarantee Credit = 28.60: Housing Benefit and Council @@ -235,19 +239,19 @@ input: people: claimant_1: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 4_100 carer_1: - age: 70 + age: 72 state_pension: 0 care_hours: 35 claimant_2: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 4_200 carer_2: - age: 70 + age: 72 state_pension: 0 care_hours: 35 benunits: @@ -282,6 +286,8 @@ council_tax_benefit: [2_000, 2_000] - name: Pension-age Housing Benefit counts Carer Support Payment but not the Scottish Carer Supplement + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # HB (SPC) Regs 2006 reg 29(1)(j)(xviiha), inserted by SI 2026/246 art 21(3). # Income is above the minimum guarantee, so no Guarantee Credit passport. # Nobody pays income tax: 12,000 and 6,000 + 5,103.80 are each below the @@ -291,11 +297,11 @@ input: people: claimant: - age: 70 + age: 72 state_pension: 0 private_pension_income: 12_000 carer: - age: 70 + age: 72 state_pension: 0 private_pension_income: 6_000 care_hours: 35 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 6e52f66d3d..917acda226 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -13,12 +13,14 @@ # central path in 2039-40, where the State Pension figures below come from. - name: England guarantee credit recipient with income above the applicable amount gets maximum CTR + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -73,12 +75,14 @@ council_tax_reduction: 2_000 - name: Entitled to guarantee credit but not claiming Pension Credit keeps the taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -103,12 +107,14 @@ council_tax_reduction: 1_165.68 - name: England pensioner without Pension Credit keeps the taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -508,12 +514,14 @@ council_tax_reduction: 1_999.60 - name: Capital of exactly 16,000 is within the limit for a pensioner without Pension Credit + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 13_000 benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml index c077000081..1f42bf1d23 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml @@ -419,12 +419,14 @@ council_tax_reduction_pensioner_earnings_disregard: 0 - name: England pensioner with State Pension and earnings has 5 a week of earnings disregarded in CTR + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 15_000 employment_income: 1_000 benunits: From 816a4e1ea156f138b054364ce3822e07a3d27a9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:34:20 -0400 Subject: [PATCH 017/224] 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 21412b6a177cfacfb8974ecdec962e7a9a32be85 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:40:47 -0400 Subject: [PATCH 018/224] Count corporate_wealth's components in every means-test capital list The six capital sources lists (UC, HB, IS, JSA, ESA, PC) name directly_held_shares, unit_and_investment_trusts and stocks_and_shares_isa in place of corporate_wealth, plus a new formula variable, unitemised_corporate_wealth = max(0, corporate_wealth - components), for whatever a dataset does not itemise. Datasets without the components keep counting corporate_wealth in full; datasets that build corporate_wealth as the components' exact sum count the same total, now itemised, so each component can carry its own valuation rule (the sale-expense deduction stacked on #1969 exempts unit trusts and ISAs). No dataset's capital changes: a differential property test checks that every programme's assessable capital equals the same holdings entered as one corporate_wealth. Review nits: the growth-factor sentence now matches uprating_indices.yaml exactly; the variable docs cite the DMG for the legacy benefits, the expert valuation of unquoted shares and the quoted-share rule for investment trusts. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- docs/book/assumptions/growthfactors.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 10 +- .../gov/dwp/JSA/income/capital/sources.yaml | 10 +- .../means_test/capital/sources.yaml | 12 +- .../means_test/capital/sources.yaml | 6 +- .../income/capital/sources.yaml | 7 +- .../means_test/capital/sources.yaml | 13 ++- .../tests/test_capital_share_components.py | 110 ++++++++++++++++++ .../wealth/unitemised_corporate_wealth.py | 34 ++++++ .../variables/input/directly_held_shares.py | 17 +-- .../input/unit_and_investment_trusts.py | 12 +- 12 files changed, 201 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/tests/test_capital_share_components.py create mode 100644 policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index 78001b6ac6..aa4202f98b 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count them and the stocks and shares ISA in place of `corporate_wealth` in every means-test capital sources list (with `unitemised_corporate_wealth` carrying any part a dataset does not itemise), and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 5a90a2e171..626965f0d6 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 3eb35b83ef..e0bd6f429d 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + held directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 9 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -33,6 +32,9 @@ values: 2008-10-27: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 6484484ab8..e6e2f55294 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 8 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -34,6 +33,9 @@ values: 1996-10-07: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index e313576158..d4522d2197 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -3,10 +3,9 @@ description: > The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings are `savings` for liquid capital, `owned_land` for land held directly, - `corporate_wealth` as the broad available proxy for share-like financial - assets such as stocks, shares, unit trusts, and investment ISAs, + the share-like sources, `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. + home, and `non_residential_property_value` for other land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the @@ -15,7 +14,7 @@ description: > pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it - inside that countable bucket. Other Schedule 6 disregards within these broad + inside `unitemised_corporate_wealth`. Other Schedule 6 disregards within these broad asset classes are not yet split out separately. Because the dataset only provides household-level capital stocks, the model allocates them across benunits using an adult-share proxy. @@ -42,6 +41,9 @@ values: 2006-04-10: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 5b28814633..336efd6e3f 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,6 +3,7 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. + `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Income Support capital sources period: year @@ -22,6 +23,9 @@ values: 2010-01-01: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 0761c1b0f3..d5fb5b4388 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,7 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. + (regulation 18) is not modelled. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,4 +25,7 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index f64474cebb..82af9db7cb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -4,15 +4,17 @@ description: > treated as income or disregarded under regulation 48 and Schedule 10. The current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the - home, `non_residential_property_value` for other land and premises, and - `corporate_wealth` for shares and similar investment assets. `main_residence_value` + home, `non_residential_property_value` for other land and premises, and the + share-like sources. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 10 paragraph 1 disregards premises occupied as the person's home. `private_pension_wealth` is excluded because Schedule 10 paragraph 10 disregards the value of any right to receive a pension under an occupational or personal pension scheme (or any other scheme registered under section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead. Other Schedule 10 disregards within these broad asset + actually drawn counts as unearned income instead; datasets built before it + was split out of `corporate_wealth` still carry it inside + `unitemised_corporate_wealth`. Other Schedule 10 disregards within these broad asset classes are not yet split out separately. Where only household-level capital is available for a multi-benunit household, the model uses a household-wide adult-share proxy for residual capital because the dataset does not identify @@ -45,4 +47,7 @@ values: - savings - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py new file mode 100644 index 0000000000..8d4a5201ce --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,110 @@ +"""Itemising corporate_wealth never changes a household's capital total. + +The means-test capital sources list corporate_wealth's components and +unitemised_corporate_wealth (the part a dataset does not itemise) instead of +corporate_wealth. With no valuation rule that tells the components apart, every +programme's assessable capital must equal what it is when the same holdings are +entered as one unitemised corporate_wealth. +""" + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +SHARE_LIKE_SOURCES = (*COMPONENTS, "unitemised_corporate_wealth") +SOURCE_LISTS = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital.sources", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital.sources", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", +} +AGE = {"pension_credit_assessable_capital": 70} +YEAR = 2026 + +system = CountryTaxBenefitSystem() +amount = st.integers(min_value=0, max_value=60_000) + + +def _parameter(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node(f"{YEAR}-01-01") + + +def _capital(variable, household): + situation = { + "people": {"adult": {"age": {YEAR: AGE.get(variable, 30)}}}, + "benunits": {"benunit": {"members": ["adult"], "would_claim_uc": {YEAR: True}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + return Simulation(situation=situation).calculate(variable, YEAR)[0] + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +def test_lists_name_the_components_not_corporate_wealth(variable): + sources = list(_parameter(SOURCE_LISTS[variable])) + assert "corporate_wealth" not in sources + assert set(SHARE_LIKE_SOURCES) <= set(sources) + + +@settings(max_examples=40, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_unitemised_corporate_wealth_is_the_floored_remainder( + savings, corporate_wealth, components +): + household = {"savings": savings, "corporate_wealth": corporate_wealth} + household.update(dict(zip(COMPONENTS, components))) + situation = { + "people": {"adult": {"age": {YEAR: 30}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + residual = Simulation(situation=situation).calculate( + "unitemised_corporate_wealth", YEAR + )[0] + assert residual == max(0, corporate_wealth - sum(components)) + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +@settings(max_examples=15, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_itemising_never_changes_capital( + variable, savings, corporate_wealth, components +): + """Split holdings count exactly as max(corporate_wealth, their sum) does.""" + itemised = {"savings": savings, "corporate_wealth": corporate_wealth} + itemised.update(dict(zip(COMPONENTS, components))) + unitemised = { + "savings": savings, + "corporate_wealth": max(corporate_wealth, sum(components)), + } + assert _capital(variable, itemised) == pytest.approx( + _capital(variable, unitemised), abs=1e-2 + ) diff --git a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py new file mode 100644 index 0000000000..1d7fcd56a1 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class unitemised_corporate_wealth(Variable): + label = "corporate wealth not itemised into components" + documentation = ( + "The part of corporate_wealth not carried in its itemised components " + "directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa, floored at nil. On datasets without the " + "components it equals corporate_wealth (including the private pension " + "wealth that datasets built before that split fold into it); on datasets " + "that build corporate_wealth as the exact sum of the components it is " + "about nil. The means-test capital sources list the components and this " + "residual instead of corporate_wealth, so each itemised asset can carry " + "its own valuation rule while datasets without the split keep counting " + "corporate_wealth as before, and no holding is counted twice." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + return max_(0, household("corporate_wealth", period) - itemised) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py index 71f82217f4..5fe9fd079f 100644 --- a/policyengine_uk/variables/input/directly_held_shares.py +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -5,13 +5,16 @@ class directly_held_shares(Variable): label = "directly held shares" documentation = ( "Value of shares the household holds directly, outside ISAs and pooled " - "funds: shares in UK companies, listed or not, and employee shares and " - "share options. Part of corporate_wealth, so the two must never be " - "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " - "(UK shares plus employee shares and options); overseas shares are not " - "yet imputed. Quoted shares are valued for the means tests at their " - "price less 10% for the expenses of sale (Advice for Decision Making " - "H1665)." + "funds: shares in UK companies, listed or not (the Wealth and Assets " + "Survey question), and employee shares and share options. Part of " + "corporate_wealth, so the two must never be summed. Imputed for the " + "Enhanced FRS from the Wealth and Assets Survey's UK shares and " + "employee shares and options (policyengine-uk-data#501); overseas " + "shares are not imputed. Quoted shares are valued at their price less " + "10% for the expenses of sale (Advice for Decision Making H1665 for " + "Universal Credit; Decision Makers' Guide 29671, 52671 and 84763 for " + "Income Support and JSA, ESA and Pension Credit); unquoted shares need " + "an expert valuation (ADM H1679-H1681)." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py index 07e59aaee1..2352d63f49 100644 --- a/policyengine_uk/variables/input/unit_and_investment_trusts.py +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -6,11 +6,13 @@ class unit_and_investment_trusts(Variable): documentation = ( "Value of the household's holdings of unit trusts and investment trusts " "outside ISAs. The Wealth and Assets Survey asks about both in one " - "question, so the Enhanced FRS cannot separate them. Part of " - "corporate_wealth, so the two must never be summed. Unit trusts are " - "valued for the means tests at the price the manager pays on " - "withdrawal, with no deduction for expenses of sale (Advice for " - "Decision Making H1673-H1674)." + "question, so the Enhanced FRS cannot separate them " + "(policyengine-uk-data#501). Part of corporate_wealth, so the two must " + "never be summed. Unit trusts are valued at the bid price with no " + "deduction for expenses of sale, because the holder withdraws through " + "the manager (Advice for Decision Making H1673-H1674; Decision Makers' " + "Guide 29680-29681). Investment trusts are companies whose shares are " + "quoted, so they fall under the quoted-share rule (ADM H1665)." ) entity = Household definition_period = YEAR From b1a660045033bafd67a6f45ef8a5d7e17e798bbb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:57:09 -0400 Subject: [PATCH 019/224] Exempt unit trusts and stocks and shares ISAs from the sale-expense deduction corporate_wealth is now itemised in the capital sources lists (#1974), so the sale-expense lists name directly_held_shares, which DWP values less 10% for costs of sale (ADM H1665; DMG 29671/52671/84763; HBGM W1.513), and unitemised_corporate_wealth, which keeps the previous flat treatment on datasets that do not itemise. Unit trusts have no costs of sale even when sold through a broker (ADM H1673-H1674; DMG 29680-29681, 52680-52681, 84771-84772; HBGM W1.530-W1.531), and an ISA is valued at what the holder would get by withdrawing it, with no paragraph applying the 10% (ADM H1656-H1658; DMG 29662-29664, 52662, 84748-84750). The survey bucket of unit and investment trusts is treated as having no costs of sale; investment trusts were 8.2% and 6.9% of the pooled funds in stocks and shares ISAs at 5 April 2022 and 2025 (HMRC ISA Table 9.6). The property tests keep their own legal asset set and assert each programme's list against it, and gain the split invariants: the residual, backward compatibility with every component nil, conservation at a zero rate, split effect = rate x (trusts + ISA), and no double counting. #1974's itemising-invariance test no longer holds by design and is replaced by them. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sale-expense-share-split.fixed.md | 1 + .../income/capital/sale_expenses/sources.yaml | 37 ++- .../income/capital/sale_expenses/sources.yaml | 36 ++- .../capital/sale_expenses/sources.yaml | 36 ++- .../capital/sale_expenses/sources.yaml | 36 ++- .../income/capital/sale_expenses/sources.yaml | 38 ++- .../capital/sale_expenses/sources.yaml | 34 ++- .../dwp/capital_valuation_share_split.yaml | 260 ++++++++++++++++ .../tests/test_capital_share_components.py | 34 +-- .../test_capital_valuation_properties.py | 287 +++++++++++++++--- .../tests/test_stock_capital_periods.py | 36 +++ 11 files changed, 710 insertions(+), 125 deletions(-) create mode 100644 changelog.d/sale-expense-share-split.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml diff --git a/changelog.d/sale-expense-share-split.fixed.md b/changelog.d/sale-expense-share-split.fixed.md new file mode 100644 index 0000000000..1060d7977b --- /dev/null +++ b/changelog.d/sale-expense-share-split.fixed.md @@ -0,0 +1 @@ +- Apply the 10% sale-expense deduction to directly held shares only, so unit and investment trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value in every means test; datasets that do not itemise `corporate_wealth` keep its previous treatment. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index 40f6f3fb9a..0f05cea7e0 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,28 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 52620-52623 and 52685). `corporate_wealth` is listed because + sale (DMG 52620-52623 and 52685). `directly_held_shares` is listed because shares are valued less 10% for the costs of selling them (DMG 52671). - It is a mixed variable, though: it also holds unit trusts, which have no - costs of sale (DMG 52681), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as income-related ESA capital are ignored. + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 52680-52681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 52662-52664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as income-related ESA capital are + ignored. metadata: label: Income-related ESA capital sources with sale expenses period: year @@ -27,13 +41,16 @@ metadata: href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=115 - title: Decision Makers' Guide chapter 52, para. 52623 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 + - title: Decision Makers' Guide chapter 52, paras. 52662-52664 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=124 - title: Decision Makers' Guide chapter 52, para. 52671 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=126 - - title: Decision Makers' Guide chapter 52, paras. 52681 and 52685 - href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=128 + - title: Decision Makers' Guide chapter 52, paras. 52680-52681 and 52685 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=127 values: 2008-10-27: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index 538833841b..db4d9e0322 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as income-based JSA capital are ignored. + sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 29680-29681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as income-based JSA capital are ignored. metadata: label: Income-based JSA capital sources with sale expenses period: year @@ -25,13 +38,16 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 1996-10-07: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index f57c9f5bd7..84284223d9 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -7,14 +7,29 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (HBGM BW1 Annex F and BP1 F2.03). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (HBGM W1.513). It - is a mixed variable, though: it also holds unit trusts, which have no costs - of sale (HBGM W1.530-W1.531), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as Housing Benefit capital are ignored. + sale (HBGM BW1 Annex F and BP1 F2.03). `directly_held_shares` is listed + because shares are valued less 10% for selling costs (HBGM W1.513 and + P1.513). `unit_and_investment_trusts` is deliberately not listed: unit trust + holdings are valued at the bid price, which already takes account of the + selling expenses, and the 10% is not deducted even if they could be sold + through a bank or stockbroker (HBGM W1.530-W1.531 and P1.530-P1.531). + Investment trusts are not named in the guidance and, as listed company + shares, arguably take the 10%, but the Wealth and Assets Survey asks about + unit and investment trusts in one question, so the whole bucket is treated + as having no costs of sale. That approximation counts investment trusts at + full value; HMRC ISA statistics (table 9.6) put investment trusts at about 7% + to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed either: the HB + guidance manual has no ISA paragraph, and the DWP guidance on the parallel + Income Support and JSA rule values an ISA at what the person would get by + withdrawing it, with no ISA paragraph applying the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Housing Benefit capital are ignored. metadata: label: Housing Benefit capital sources with sale expenses period: year @@ -40,6 +55,8 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 - title: Housing Benefit Guidance Manual BP1, paras. P1.513 and P1.530-P1.531 href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=38 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 (ISAs, parallel IS and JSA guidance) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Housing Benefit Guidance Manual BW1, Annex F href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=93 - title: Housing Benefit Guidance Manual BP1, paras. F2.00-F2.03 @@ -49,4 +66,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index baa3504bda..06188a12fd 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Income Support capital are ignored. + sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 29671). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 29680-29681). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 29662-29664). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Income Support capital are ignored. metadata: label: Income Support capital sources with sale expenses period: year @@ -25,13 +38,16 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 2010-01-01: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index 1dbbd6816f..dae92445b4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 84712-84715 and 84731). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 84763). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 84772), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Pension Credit capital are ignored. + sale (DMG 84712-84715 and 84731). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (DMG 84763). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (DMG + 84771-84772). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (DMG 84748-84750). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Pension Credit capital are ignored. metadata: label: Pension Credit capital sources with sale expenses period: year @@ -27,13 +40,16 @@ metadata: href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 - title: Decision Makers' Guide chapter 84, para. 84731 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 + - title: Decision Makers' Guide chapter 84, paras. 84748-84750 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=105 - title: Decision Makers' Guide chapter 84, para. 84763 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=107 - - title: Decision Makers' Guide chapter 84, para. 84772 - href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=109 + - title: Decision Makers' Guide chapter 84, paras. 84771-84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=108 values: 2003-10-06: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index fe25cbe7d8..f7a760beb9 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -6,14 +6,27 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (ADM H1604-H1607 and H1675). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (ADM H1665). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (ADM H1674), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Universal Credit capital are ignored. + sale (ADM H1604-H1607 and H1675). `directly_held_shares` is listed because + shares are valued less 10% for the costs of selling them (ADM H1665). + `unit_and_investment_trusts` is deliberately not listed: a unit trust is + valued at its bid price and has no costs of sale, because the person + withdraws from the manager, even if they use a stockbroker (ADM + H1673-H1674). Investment trusts are not named in the guidance and, as listed + company shares, arguably take the 10%, but the Wealth and Assets Survey asks + about unit and investment trusts in one question, so the whole bucket is + treated as having no costs of sale. That approximation counts investment + trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts + at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April + 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed + either: an ISA is valued at what the person would get by withdrawing it, and + no ISA paragraph applies the 10% (ADM H1656-H1658). + `unitemised_corporate_wealth` is listed so that datasets which do not + itemise `corporate_wealth` keep its previous flat treatment: there it equals + `corporate_wealth`, which mixes shares with unit trusts, stocks and shares + ISAs and employee share options, and, on datasets built before private + pension wealth was split out, non-defined-benefit pension wealth, which is + not capital at all. The deduction reaches all of these until the data + separate them. Sources not counted as Universal Credit capital are ignored. metadata: label: Universal Credit capital sources with sale expenses period: year @@ -25,7 +38,7 @@ metadata: href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 - title: Advice for Decision Making chapter H1, paras. H1604-H1607 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 - - title: Advice for Decision Making chapter H1, para. H1656 + - title: Advice for Decision Making chapter H1, paras. H1656-H1658 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 - title: Advice for Decision Making chapter H1, para. H1665 href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 @@ -36,4 +49,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unitemised_corporate_wealth diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml new file mode 100644 index 0000000000..d1c1a17514 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml @@ -0,0 +1,260 @@ +# corporate_wealth itemised into directly_held_shares, unit_and_investment_trusts +# and stocks_and_shares_isa, with unitemised_corporate_wealth carrying whatever +# is not itemised (max(0, corporate_wealth - the three components)). +# Capital is valued at market value less 10% where a sale would incur expenses +# (UC Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA +# Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). Shares +# take the 10% (ADM H1665; DMG 29671, 52671, 84763; HBGM W1.513). Unit trusts +# have no costs of sale (ADM H1673-H1674; DMG 29680-29681, 52680-52681, +# 84771-84772; HBGM W1.530-W1.531), and the survey bucket of unit and +# investment trusts is treated the same. An ISA is valued at what the person +# would get by withdrawing it, with no 10% (ADM H1656; DMG 29662, 52662, +# 84748). Expected values are worked by hand from those rules. + +- name: UC unsplit corporate_wealth keeps the flat 10% treatment + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # No components, so the residual is all 12,000 of corporate_wealth: + # 5,000 + 90% of 12,000 = 15,800, the same as before the split. + savings: 5_000 + corporate_wealth: 12_000 + output: + unitemised_corporate_wealth: 12_000 + uc_assessable_capital: 15_800 + is_uc_eligible: true + # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. + uc_tariff_income: 2_088 + +- name: UC split of the same total exempts trusts and ISAs and crosses the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 12,000 - (4,000 + 3,000 + 5,000)) = 0. Capital is + # 5,000 + 90% of 4,000 + 3,000 + 5,000 = 16,600, above the 16,000 + # limit; unsplit, the same household held 15,800 and qualified. + savings: 5_000 + corporate_wealth: 12_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 16_600 + is_uc_eligible: false + +- name: UC counts the components when corporate_wealth is not given + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 0 - 15,600) = 0. Capital is + # 1,000 + 90% of 10,000 + 2,600 + 3,000 = 15,600. + savings: 1_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 2_600 + stocks_and_shares_isa: 3_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 15_600 + is_uc_eligible: true + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up): 39 * 4.35 * 12. + uc_tariff_income: 2_035.8 + +- name: UC partial split values the unitemised residual less 10% + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Only the ISA is itemised: residual 12,000 - 5,000 = 7,000. Share-like + # capital is 90% of 7,000 + 5,000 = 11,300; with savings 15,300 + # (unsplit it would be 4,000 + 10,800 = 14,800). + savings: 4_000 + corporate_wealth: 12_000 + stocks_and_shares_isa: 5_000 + output: + unitemised_corporate_wealth: 7_000 + uc_assessable_capital: 15_300 + # 9,300 above 6,000 is 38 steps of 250 (37.2 rounded up): 38 * 4.35 * 12. + uc_tariff_income: 1_983.6 + +- name: UC components exceeding corporate_wealth leave a nil residual + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The components (7,000) exceed corporate_wealth (5,000), so the + # residual is max(0, -2,000) = 0, not a negative offset. Capital is + # 1,000 + 90% of 4,000 + 3,000 = 7,600. + savings: 1_000 + corporate_wealth: 5_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + output: + unitemised_corporate_wealth: 0 + uc_assessable_capital: 7_600 + # 1,600 above 6,000 is 7 steps of 250 (6.4 rounded up): 7 * 4.35 * 12. + uc_tariff_income: 365.4 + +- name: Housing Benefit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # Residual 0. 1,100 + 90% of 5,000 + 4,000 + 6,000 = 15,600 (HBGM + # W1.513, W1.530); unsplit it would be 1,100 + 13,500 = 14,600. + savings: 1_100 + corporate_wealth: 15_000 + directly_held_shares: 5_000 + unit_and_investment_trusts: 4_000 + stocks_and_shares_isa: 6_000 + output: + unitemised_corporate_wealth: 0 + housing_benefit_assessable_capital: 15_600 + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up) at 1 a week. + housing_benefit_tariff_income: 2_028 + housing_benefit_eligible: true + +- name: Income Support split exempts unit trusts + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 90% of 6,000 + 4,000 = 9,400 (DMG 29671, 29681); + # unsplit it would be 9,000. + corporate_wealth: 10_000 + directly_held_shares: 6_000 + unit_and_investment_trusts: 4_000 + output: + income_support_assessable_capital: 9_400 + +- name: Income-based JSA split exempts stocks and shares ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,000 + 90% of 3,000 + 6,000 = 10,700 (DMG 29662, + # 29671); unsplit it would be 2,000 + 8,100 = 10,100. + savings: 2_000 + corporate_wealth: 9_000 + directly_held_shares: 3_000 + stocks_and_shares_isa: 6_000 + output: + jsa_income_assessable_capital: 10_700 + +- name: Income-related ESA split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 3,000 + 90% of 2,000 + 3,000 + 5,000 = 12,800 (DMG + # 52662, 52671, 52681); unsplit it would be 3,000 + 9,000 = 12,000. + savings: 3_000 + corporate_wealth: 10_000 + directly_held_shares: 2_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + esa_income_assessable_capital: 12_800 + +- name: Pension Credit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,100 + 90% of 10,000 + 8,000 + 12,000 = 31,100 (DMG + # 84748, 84763, 84772); unsplit it would be 2,100 + 27,000 = 29,100. + savings: 2_100 + corporate_wealth: 30_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 8_000 + stocks_and_shares_isa: 12_000 + output: + pension_credit_assessable_capital: 31_100 + # 21,100 above 10,000 is 43 steps of 500 (42.2 rounded up) at 1 a week. + pension_credit_deemed_income: 2_236 diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 8d4a5201ce..885d66333f 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -1,10 +1,10 @@ -"""Itemising corporate_wealth never changes a household's capital total. +"""The means-test capital sources itemise corporate_wealth. -The means-test capital sources list corporate_wealth's components and -unitemised_corporate_wealth (the part a dataset does not itemise) instead of -corporate_wealth. With no valuation rule that tells the components apart, every -programme's assessable capital must equal what it is when the same holdings are -entered as one unitemised corporate_wealth. +The lists name corporate_wealth's components and unitemised_corporate_wealth +(the part a dataset does not itemise) instead of corporate_wealth. Since the +sale-expense lists tell the components apart, itemising changes capital by +exactly rate x (trusts + ISA): that, conservation at a zero rate and the other +valuation invariants are in test_capital_valuation_properties.py. """ import pytest @@ -86,25 +86,3 @@ def test_unitemised_corporate_wealth_is_the_floored_remainder( "unitemised_corporate_wealth", YEAR )[0] assert residual == max(0, corporate_wealth - sum(components)) - - -@pytest.mark.parametrize("variable", SOURCE_LISTS) -@settings(max_examples=15, deadline=None) -@given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), -) -def test_itemising_never_changes_capital( - variable, savings, corporate_wealth, components -): - """Split holdings count exactly as max(corporate_wealth, their sum) does.""" - itemised = {"savings": savings, "corporate_wealth": corporate_wealth} - itemised.update(dict(zip(COMPONENTS, components))) - unitemised = { - "savings": savings, - "corporate_wealth": max(corporate_wealth, sum(components)), - } - assert _capital(variable, itemised) == pytest.approx( - _capital(variable, unitemised), abs=1e-2 - ) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 3ccf6d7eb3..4e77f884b9 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -7,9 +7,19 @@ Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). A bank or building society account is valued at its balance (ADM H1675); land and property always have costs of sale (H1606) and shares -are valued less 10% (H1665). +are valued less 10% (H1665). Unit trusts have no costs of sale (H1673-H1674) +and an ISA is valued at what the person would get by withdrawing it (H1656). -Invariants, for any generated population of single-adult households: +corporate_wealth may be itemised into directly_held_shares, +unit_and_investment_trusts and stocks_and_shares_isa; unitemised_corporate_wealth +carries whatever is not itemised, so it is all of corporate_wealth on datasets +without the components and about nil on datasets that build corporate_wealth as +their exact sum. The survey bucket of unit and investment trusts is treated as +having no costs of sale, an approximation for its investment-trust part. + +Invariants, for any generated population of single-adult households, whose +share-like holdings are unsplit, split consistently (corporate_wealth equal to +the components' sum), partly split, given as components only, or drawn freely: 1. Non-increasing: every programme's assessable capital is at most the same household's capital with the deduction switched off (rate 0, no debt). @@ -17,14 +27,27 @@ deduction on or off. 3. Differential: capital equals a direct implementation of the regulation, savings + sum over sources of max(0, 90% of value - secured debt), where - the test itself fixes which assets take the 10% (land, property and - shares, not cash), so a wrong parameter list fails it. + the test itself fixes which assets count and which take the 10% (land, + property, directly held shares and unitemised corporate wealth; not cash, + trusts or ISAs), so a wrong parameter list fails it. 4. Monotone: a higher sale-expense rate, or more secured debt, never raises capital. 5. Isolation: debt secured on one source never takes capital below the value of the household's unencumbered sources, and capital is never negative. (Isolation between two encumbered sources is pinned by the differential and by a YAML case.) +6. Residual: unitemised_corporate_wealth is never negative and equals + max(0, corporate_wealth - sum of components). +7. Backward compatibility: with every component nil, every programme's capital + is bit-for-bit the capital under a reform restoring the source lists used + before the split. +8. Conservation: at rate 0, a consistent split leaves capital unchanged. +9. Split effect: a consistent split less the unsplit household is rate x + (trusts + ISA), which lies in [0, rate x corporate_wealth]. +10. No double counting: capital is at most the other assets plus + max(corporate_wealth, sum of components). +11. Aggregates: total_wealth, net_wealth and corporate_sector_wealth do not + change with the component inputs. """ from functools import reduce @@ -33,7 +56,7 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from policyengine_uk import Simulation +from policyengine_uk import CountryTaxBenefitSystem, Simulation YEAR = 2026 PROPERTY_SETTINGS = settings( @@ -56,45 +79,105 @@ "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", } PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +RESIDUAL = "unitemised_corporate_wealth" +SHARE_LIKE = (*COMPONENTS, RESIDUAL) +PROPERTY = ("other_residential_property_value", "non_residential_property_value") +# The assets each programme counts, fixed here rather than read from the +# parameters. Universal Credit's list has never included owned_land; that +# predates the capital-valuation work and is outside these properties. +LEGACY_COUNTED = {"savings", "owned_land", *PROPERTY, *SHARE_LIKE} +COUNTED = { + variable: LEGACY_COUNTED + for variable in PROGRAMMES + if variable != "uc_assessable_capital" +} | {"uc_assessable_capital": {"savings", *PROPERTY, *SHARE_LIKE}} # The assets whose sale incurs expenses, fixed here from the law rather than -# read from the parameters: real property always (ADM H1606), shares (H1665); -# never a bank or building society balance (H1675). +# read from the parameters: real property always (ADM H1606) and shares +# (H1665); never a bank or building society balance (H1675), a unit trust +# (H1673-H1674) or an ISA (H1656). The unitemised residual keeps the flat +# treatment corporate_wealth had before it was itemised. SALE_EXPENSE_ASSETS = { "owned_land", - "other_residential_property_value", - "non_residential_property_value", - "corporate_wealth", + *PROPERTY, + "directly_held_shares", + RESIDUAL, } LEGAL_RATE = 0.1 -ASSETS = [ +# The source lists in force before corporate_wealth was itemised, for the +# backward-compatibility differential. +PRE_SPLIT_LEGACY_SOURCES = [ "savings", "owned_land", "corporate_wealth", - "other_residential_property_value", - "non_residential_property_value", + *PROPERTY, ] +PRE_SPLIT_SOURCES = { + "gov.dwp.universal_credit.means_test.capital": [ + "savings", + *PROPERTY, + "corporate_wealth", + ], + "gov.dwp.housing_benefit.means_test.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.income_support.means_test.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.JSA.income.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.ESA.income.capital": PRE_SPLIT_LEGACY_SOURCES, + "gov.dwp.pension_credit.income.capital": [ + "savings", + "owned_land", + *PROPERTY, + "corporate_wealth", + ], +} +PRE_SPLIT_SALE_EXPENSE_SOURCES = ["owned_land", *PROPERTY, "corporate_wealth"] +BASE_ASSETS = ["savings", "owned_land", "corporate_wealth", *PROPERTY] DEBTS = { "owned_land": "owned_land_secured_debt", "other_residential_property_value": "other_residential_property_secured_debt", "non_residential_property_value": "non_residential_property_secured_debt", } +SPLIT_MODES = ("unsplit", "consistent", "partial", "components_only", "free") amount = st.one_of(st.just(0.0), st.floats(0, 400_000, allow_nan=False)) @st.composite -def households(draw, cash_only=False): - assets = {asset: draw(amount) for asset in ASSETS} +def households(draw, mode=None, cash_only=False, dividends=False): + mode = mode or draw(st.sampled_from(SPLIT_MODES)) + assets = {asset: draw(amount) for asset in BASE_ASSETS} + components = {component: draw(amount) for component in COMPONENTS} + if mode == "unsplit": + components = {component: 0.0 for component in COMPONENTS} + elif mode == "consistent": + assets["corporate_wealth"] = sum(components.values()) + elif mode == "partial": + components = { + component: value if draw(st.booleans()) else 0.0 + for component, value in components.items() + } + assets["corporate_wealth"] = sum(components.values()) + draw(amount) + elif mode == "components_only": + assets["corporate_wealth"] = 0.0 + assets |= components debts = {debt: draw(amount) for debt in DEBTS.values()} if cash_only: - assets = {asset: 0.0 for asset in ASSETS} | {"savings": assets["savings"]} + assets = {asset: 0.0 for asset in assets} | {"savings": assets["savings"]} debts = {debt: 0.0 for debt in debts} - return dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + unit = dict(age=draw(st.sampled_from([30, 70])), assets=assets, debts=debts) + if dividends: + unit["dividend_income"] = draw(st.floats(0, 50_000, allow_nan=False)) + return unit -def situation(units, debt_scale=1.0): +def situation(units, debt_scale=1.0, dividends=True): people, benunits, homes = {}, {}, {} for i, unit in enumerate(units): people[f"p{i}"] = {"age": {YEAR: unit["age"]}} + if dividends and "dividend_income" in unit: + people[f"p{i}"]["dividend_income"] = {YEAR: unit["dividend_income"]} benunits[f"b{i}"] = {"members": [f"p{i}"]} homes[f"h{i}"] = { "members": [f"p{i}"], @@ -110,16 +193,46 @@ def rate_reform(rate): } -def calculate(units, rate=None, debt_scale=1.0): +PRE_SPLIT_REFORM = { + f"{node}.{parameter}": {str(YEAR): value} + for node, sources in PRE_SPLIT_SOURCES.items() + for parameter, value in ( + ("sources", sources), + ("sale_expenses.sources", PRE_SPLIT_SALE_EXPENSE_SOURCES), + ) +} +EXTRA_OUTPUTS = ( + "guarantee_credit", + RESIDUAL, + "uc_tariff_income", + "uc_unearned_income", + "pension_credit_deemed_income", + "total_wealth", + "net_wealth", + "corporate_sector_wealth", +) + + +def calculate(units, rate=None, debt_scale=1.0, reform=None, dividends=True): + if rate is not None: + reform = (reform or {}) | rate_reform(rate) sim = Simulation( - situation=situation(units, debt_scale), - reform=None if rate is None else rate_reform(rate), + situation=situation(units, debt_scale, dividends), + reform=reform, ) - values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} - values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + values = { + v: np.asarray(sim.calculate(v, YEAR), dtype=float) + for v in (*PROGRAMMES, *EXTRA_OUTPUTS) + } return sim, values +def without_components(unit): + """The same household with corporate_wealth left unitemised.""" + assets = unit["assets"] | {component: 0.0 for component in COMPONENTS} + return unit | {"assets": assets} + + def parameter_node(sim, path): return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) @@ -130,21 +243,43 @@ def applies(variable, unit, guarantee_credit=0.0): return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) -def expected_capital(unit, node): +def residual(unit): + assets = unit["assets"] + return max(0.0, assets["corporate_wealth"] - sum(assets[c] for c in COMPONENTS)) + + +def share_like_total(unit): + """max(corporate_wealth, sum of components): the share-like holdings once.""" + return sum(unit["assets"][c] for c in COMPONENTS) + residual(unit) + + +def expected_capital(unit, variable, rate=LEGAL_RATE): """Reg. 49(1) applied source by source, independently of the model.""" + values = unit["assets"] | {RESIDUAL: residual(unit)} total = 0.0 - for source in node.sources: - value = unit["assets"][source] + for source in COUNTED[variable]: + value = values[source] if source in SALE_EXPENSE_ASSETS: - value *= 1 - LEGAL_RATE + value *= 1 - rate if source in DEBTS: value = max(0.0, value - unit["debts"][DEBTS[source]]) total += value return total -def close(a, b): - return abs(a - b) <= 1 + 1e-6 * abs(b) +def close(a, b, scale=0.0): + """Equal up to float32 rounding of totals as large as ``b`` or ``scale``.""" + return abs(a - b) <= 1 + 1e-6 * max(abs(b), abs(scale)) + + +def test_source_lists_match_the_legal_asset_sets(): + parameters = CountryTaxBenefitSystem().parameters(YEAR) + for variable, path in PROGRAMMES.items(): + node = reduce(getattr, path.split("."), parameters) + assert set(node.sources) == COUNTED[variable], variable + assert set(node.sale_expenses.sources) == SALE_EXPENSE_ASSETS, variable + assert "corporate_wealth" not in node.sources, variable + assert node.sale_expenses.rate == LEGAL_RATE, variable @PROPERTY_SETTINGS @@ -155,11 +290,7 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): for variable, path in PROGRAMMES.items(): node = parameter_node(sim, path) assert node.sale_expenses.rate == LEGAL_RATE - assert "savings" in node.sources - counted = set(node.sources) - assert set(node.sale_expenses.sources) & counted == ( - SALE_EXPENSE_ASSETS & counted - ), variable + assert set(node.sale_expenses.sources) == SALE_EXPENSE_ASSETS, variable for i, unit in enumerate(units): gc = valued["guarantee_credit"][i] assert valued[variable][i] >= 0, (variable, unit) @@ -168,15 +299,97 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): if gc > 0: assert valued[variable][i] == 0, unit if applies(variable, unit, gc): - expected = expected_capital(unit, node) + expected = expected_capital(unit, variable) assert close(valued[variable][i], expected), (variable, unit) # Debt secured on property never reaches the other assets. + values = unit["assets"] | {RESIDUAL: residual(unit)} floor = sum( - unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) - for s in node.sources + values[s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + for s in COUNTED[variable] if s not in DEBTS ) assert valued[variable][i] >= floor - 1, (variable, unit) + # Each share-like holding is counted at most once. + other = sum( + unit["assets"][s] for s in COUNTED[variable] if s not in SHARE_LIKE + ) + ceiling = other + share_like_total(unit) + assert valued[variable][i] <= ceiling + 1 + 1e-6 * ceiling, ( + variable, + unit, + ) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_residual_is_corporate_wealth_less_its_components_floored_at_nil(units): + _, valued = calculate(units) + for i, unit in enumerate(units): + assert valued[RESIDUAL][i] >= 0, unit + assert close(valued[RESIDUAL][i], residual(unit)), unit + if all(unit["assets"][c] == 0 for c in COMPONENTS): + assert valued[RESIDUAL][i] == np.float32(unit["assets"]["corporate_wealth"]) + + +@PROPERTY_SETTINGS +@given(st.lists(households(mode="unsplit"), min_size=20, max_size=200)) +def test_unsplit_datasets_match_the_pre_split_source_lists_exactly(units): + _, split_lists = calculate(units) + _, pre_split = calculate(units, reform=PRE_SPLIT_REFORM) + for variable in ( + *PROGRAMMES, + "guarantee_credit", + "uc_tariff_income", + "uc_unearned_income", + "pension_credit_deemed_income", + ): + assert np.array_equal(split_lists[variable], pre_split[variable]), variable + + +@PROPERTY_SETTINGS +@given( + st.lists(households(mode="consistent"), min_size=20, max_size=200), + st.one_of(st.just(LEGAL_RATE), st.floats(0, 0.5)), +) +def test_a_consistent_split_moves_capital_only_by_the_exempt_holdings(units, rate): + unsplit_units = [without_components(unit) for unit in units] + _, split0 = calculate(units, rate=0.0) + _, unsplit0 = calculate(unsplit_units, rate=0.0) + _, split = calculate(units, rate=rate) + _, unsplit = calculate(unsplit_units, rate=rate) + for variable in PROGRAMMES: + for i, unit in enumerate(units): + if not ( + applies(variable, unit, split0["guarantee_credit"][i]) + and applies(variable, unit, unsplit0["guarantee_credit"][i]) + ): + continue + # At rate 0 itemising corporate_wealth conserves capital. + assert close(split0[variable][i], unsplit0[variable][i]), (variable, unit) + if not ( + applies(variable, unit, split["guarantee_credit"][i]) + and applies(variable, unit, unsplit["guarantee_credit"][i]) + ): + continue + exempt = ( + unit["assets"]["unit_and_investment_trusts"] + + unit["assets"]["stocks_and_shares_isa"] + ) + difference = split[variable][i] - unsplit[variable][i] + scale = max(split[variable][i], unsplit[variable][i]) + assert close(difference, rate * exempt, scale), (variable, unit, rate) + cap = rate * unit["assets"]["corporate_wealth"] + slack = 1 + 1e-6 * scale + assert -slack <= difference <= cap + slack, (variable, unit, rate) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_wealth_aggregates_ignore_the_components(units): + _, itemised = calculate(units) + _, unitemised = calculate([without_components(unit) for unit in units]) + for variable in ("total_wealth", "net_wealth", "corporate_sector_wealth"): + assert np.array_equal(itemised[variable], unitemised[variable]), variable @PROPERTY_SETTINGS diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index eead7689d9..fe6f440ddf 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -57,3 +57,39 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] == 33_600 ) + + +def test_itemised_share_like_stocks_are_not_prorated_monthly(): + stocks = { + "savings": 1_000, + "corporate_wealth": 12_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 2_000, + } + for age, capital in ( + (30, "uc_assessable_capital"), + (70, "pension_credit_assessable_capital"), + ): + situation = { + "people": {"person": {"age": {"2026": age}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + **{name: {"2026": value} for name, value in stocks.items()}, + } + }, + } + simulation = Simulation(situation=situation) + + for name, value in stocks.items(): + assert simulation.calculate(name, "2026-01")[0] == value, name + # 12,000 less the 9,000 itemised. + assert simulation.calculate("unitemised_corporate_wealth", "2026-01")[0] == ( + 3_000 + ) + # Shares and the unitemised residual less 10% for the expenses of + # sale; unit trusts and the ISA at their withdrawal value: + # 1,000 + 3,600 + 3,000 + 2,000 + 2,700. + assert simulation.calculate(capital, "2026-01")[0] == 12_300, capital From 7ef035261313e1bc26112e4f76ade77945d49dce Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 15 Sep 2026 13:05:30 +0100 Subject: [PATCH 020/224] 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 021/224] 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 022/224] 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 023/224] 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 024/224] 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 025/224] 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 aed099ca168590efaee04634ba23395d683d9c0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:08:15 -0400 Subject: [PATCH 026/224] Read the attainment date from birth_instant, and test it against state_pension_age state_pension_age_date now takes the day of birth from birth_instant, as state_pension_age does, so a given date_of_birth is respected. A differential test checks that the age implied by the attainment date is state_pension_age. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_state_pension_age.py | 20 ++++++++++++++++++ .../gov/dwp/state_pension_age_date.py | 21 +++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/tests/test_state_pension_age.py b/policyengine_uk/tests/test_state_pension_age.py index fa83514c32..7cb1558388 100644 --- a/policyengine_uk/tests/test_state_pension_age.py +++ b/policyengine_uk/tests/test_state_pension_age.py @@ -303,6 +303,26 @@ def test_attainment_date_matches_statute_for_every_birth_date(year): assert not len(wrong), [(keys[i], model[i], expected[i]) for i in wrong[:10]] +@pytest.mark.parametrize("year", [2021, 2026, 2030]) +def test_attainment_date_agrees_with_state_pension_age(year): + """Differential: state_pension_age_date and state_pension_age read the + same timetable from the same instant of birth, so the age implied by the + attainment date is state_pension_age.""" + from policyengine_uk.utils.dates import birth_instant_from_age + from policyengine_uk.utils.dates import yyyymmdd_to_grid_months + + sim, keys = differential_simulation() + attained = yyyymmdd_to_grid_months(sim.calculate("state_pension_age_date", year)) + birth = birth_instant_from_age( + year, + sim.calculate("age", year).values, + sim.calculate("months_since_last_birthday", year).values, + ) + implied = (attained - birth) / 12 + model = sim.calculate("state_pension_age", year).values + np.testing.assert_allclose(implied, model, atol=1e-9) + + def mid_year_share(year: int, age: int, male: bool) -> float: """Share of people aged `age` on 6 October who have attained pensionable age by then, with birthdays spread evenly over the year.""" diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_date.py b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py index 1fbe6532ad..d343cfd694 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_age_date.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py @@ -1,8 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.dates import ( add_months_to_yyyymmdd, - exact_age_in_months, - grid_month, + birth_instant, grid_months_to_yyyymmdd, ) @@ -13,8 +12,9 @@ class state_pension_age_date(Variable): label = "date this person attains State Pension age" documentation = ( "The day on which this person attains State Pension age (pensionable " - "age), written as a YYYYMMDD number. It is set by their date of birth, " - "which comes from age and months_since_last_birthday." + "age), written as a YYYYMMDD number. It is set by their date of birth: " + "date_of_birth where given, otherwise from age and " + "months_since_last_birthday." ) definition_period = YEAR unit = "date" @@ -25,16 +25,9 @@ class state_pension_age_date(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.state_pension.age - # Dates are in months on a grid whose months start on the 6th, so the - # middle of the fiscal year (6 October) is a whole month. - age_in_months = exact_age_in_months( - person("age", period), person("months_since_last_birthday", period) - ) - # The day of birth: the one starting at or after that instant, so the - # person's legal age on 6 October is age. - birth_date = grid_months_to_yyyymmdd( - grid_month(period.start.year, 10) - age_in_months - ) + # The day of birth, read as state_pension_age reads it: the day + # starting at or after the instant of birth (birth_instant). + birth_date = grid_months_to_yyyymmdd(birth_instant(person, period)) # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December # 1953 attain pensionable age at 65. Everyone else follows the From 8b696977b42228697ef46c4a548e7021917fe072 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:59:39 -0400 Subject: [PATCH 027/224] Keep tests on the pension-age tables under main's regulation switches Main now puts a mixed-age couple entitled to Universal Credit in the working-age tables (HB reg 5; CTR pensioner test). The mixed-age cohort tests set the pension-age switch, as for a protected couple with no Universal Credit. Main's new England CTR tests with pensioners aged 70 now use pensioners aged 72, who reached State Pension age in 2019, since their arithmetic assumes the higher allowance. Co-Authored-By: Claude Opus 5.5 --- .../housing_benefit_pension_age_personal_allowance.yaml | 6 ++++++ .../council_tax_reduction/claimant_family_scheme.yaml | 4 +++- .../council_tax_reduction_carer_support_payment.yaml | 4 +++- ...ouncil_tax_reduction_pension_age_personal_allowance.yaml | 3 +++ policyengine_uk/tests/test_state_pension_age.py | 6 +++--- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml index 457c2e0e45..9150b62ff1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -197,6 +197,9 @@ benunit: members: [older, younger] housing_benefit_eligible: true + # A protected mixed-age couple with no Universal Credit, so the + # pension-age regulations apply (HB (SPC) Regs 2006 reg 5). + housing_benefit_pension_age_regulations_apply: true benefits_premiums: 0 output: housing_benefit_attained_pension_age_before_cutoff: false @@ -219,6 +222,9 @@ benunit: members: [older, younger] housing_benefit_eligible: true + # A protected mixed-age couple with no Universal Credit, so the + # pension-age regulations apply (HB (SPC) Regs 2006 reg 5). + housing_benefit_pension_age_regulations_apply: true benefits_premiums: 0 output: housing_benefit_attained_pension_age_before_cutoff: true diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 793efe1508..ec70eab7ab 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -53,6 +53,8 @@ council_tax_reduction: 1_575 - name: Oxford, working-age head and pension-age sharer each claim under their own scheme + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: @@ -61,7 +63,7 @@ age: 40 is_household_head: true sharer: - age: 70 + age: 72 benunits: head_family: members: [head] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index eefa9a2954..3c6e9790e4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -412,12 +412,14 @@ council_tax_reduction: 2_000 - name: An English pensioner carer's Council Tax Reduction is unchanged + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: carer: - age: 70 + age: 72 state_pension: 0 private_pension_income: 14_000 care_hours: 35 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml index ae22978388..60cd801878 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -149,6 +149,9 @@ benunit: members: [older, younger] benefits_premiums: 0 + # A mixed-age couple with no Universal Credit claims as pensioners + # (SI 2012/2885 reg 3(1)(a)). + council_tax_reduction_pensioner: true households: household: members: [older, younger] diff --git a/policyengine_uk/tests/test_state_pension_age.py b/policyengine_uk/tests/test_state_pension_age.py index 7cb1558388..67541b5a61 100644 --- a/policyengine_uk/tests/test_state_pension_age.py +++ b/policyengine_uk/tests/test_state_pension_age.py @@ -315,11 +315,11 @@ def test_attainment_date_agrees_with_state_pension_age(year): attained = yyyymmdd_to_grid_months(sim.calculate("state_pension_age_date", year)) birth = birth_instant_from_age( year, - sim.calculate("age", year).values, - sim.calculate("months_since_last_birthday", year).values, + sim.calculate("age", year), + sim.calculate("months_since_last_birthday", year), ) implied = (attained - birth) / 12 - model = sim.calculate("state_pension_age", year).values + model = sim.calculate("state_pension_age", year) np.testing.assert_allclose(implied, model, atol=1e-9) From ddab487aa94f6bd06ec687c106224a42aaa53e10 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 22:09:40 -0400 Subject: [PATCH 028/224] Fix the two pytest failures CI found at 8b696977 - test_housing_benefit_pension_age_cohorts: the property tests now set the pension-age HB and CTR switches from the statute, so a mixed-age couple that main would put on Universal Credit still tests the cohort rule. - test_state_pension_credit_qualifying_age: from April 2021, England's pension-age CTR allowance can be either rate, so the consumer check accepts the lower rate too (SI 2012/2885 Sch 2 para 1). Co-Authored-By: Claude Opus 5.5 --- ...est_housing_benefit_pension_age_cohorts.py | 11 +++++++++- ...est_state_pension_credit_qualifying_age.py | 20 ++++++++++--------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 93979e538c..78378d30ba 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -56,7 +56,13 @@ def rates(year: int, couple: bool) -> tuple: def build(units: list, year: int, region: str = "LONDON") -> Simulation: """Each unit is a list of (birth, male). Units are eligible with no - premiums, so the applicable amount is 52 times the personal allowance.""" + premiums, so the applicable amount is 52 times the personal allowance. + + Which table applies is set here, not left to the model: a unit with a + member over State Pension age is under the pension-age Housing Benefit + regulations and is a Council Tax Reduction pensioner. That leaves the + cohort rule as the only thing under test (a mixed-age couple on Universal + Credit would otherwise be in the working-age tables).""" people, benunits = {}, {} mid_year = grid_months(date(year, 10, 6)) for u, members in enumerate(units): @@ -71,9 +77,12 @@ def build(units: list, year: int, region: str = "LONDON") -> Simulation: "is_male": {year: male}, } names.append(name) + pension_age = at_pension_age(members, year) benunits[f"u{u}"] = { "members": names, "housing_benefit_eligible": {year: True}, + "housing_benefit_pension_age_regulations_apply": {year: pension_age}, + "council_tax_reduction_pensioner": {year: pension_age}, "benefits_premiums": {year: 0}, } return Simulation( diff --git a/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py index a1eea4e249..ad6995b459 100644 --- a/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py +++ b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py @@ -185,16 +185,18 @@ def assert_consumers_follow_the_qualifying_age(births, male, year): sim.calculate("council_tax_reduction_pensioner", year), attained ) assert np.array_equal(sim.calculate("is_benefit_cap_exempt_other", year), attained) - aged = sim.tax_benefit_system.parameters( + allowances = sim.tax_benefit_system.parameters( f"{year}-06-01" - ).gov.dwp.housing_benefit.allowances.single.aged - assert np.array_equal( - np.isclose( - sim.calculate("council_tax_reduction_applicable_amount", year), - aged * WEEKS_IN_YEAR, - ), - attained, - ) + ).gov.dwp.housing_benefit.allowances + amount = sim.calculate("council_tax_reduction_applicable_amount", year) + at_pension_age_rate = np.isclose(amount, allowances.single.aged * WEEKS_IN_YEAR) + if allowances.pension_age_cutoff is not None: + # England's lower rate for those who attained State Pension age on or + # after 1 April 2021 (SI 2012/2885 Sch 2 para 1). + at_pension_age_rate |= np.isclose( + amount, allowances.single.aged_from_cutoff * WEEKS_IN_YEAR + ) + assert np.array_equal(at_pension_age_rate, attained) if year <= 2023: assert np.array_equal( sim.calculate("winter_fuel_allowance", year) > 0, attained From 7f704ac2b2a88495d01564086d7882decb666d5a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:53:23 -0400 Subject: [PATCH 029/224] Expect the young parents' Income Support not to passport a lone-applying head Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/claimant_family_scheme.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 793efe1508..005c41c6f2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -969,6 +969,7 @@ output: is_council_tax_reduction_applicant_or_partner: [true, false, false, false] council_tax_reduction_head_applies_alone: [true] - council_tax_reduction_relevant_income_based_benefit: [true] + # Nor does it passport her: the passport is the applicant's award. + council_tax_reduction_relevant_income_based_benefit: [false] council_tax_reduction_pensioner: [true] simulated_council_tax_reduction_benunit: [1_800] From 623d48d546e48cdeb3f98564777a3b972ac8da45 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:40:09 -0400 Subject: [PATCH 030/224] Test a disabled pensioner head applying alone; mark 19-year-old cases provisional on #2017 Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) 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 b5af6ad004..ec6d85dc67 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,6 +140,10 @@ 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. period: 2026 absolute_error_margin: 0.01 input: @@ -319,6 +323,10 @@ 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. period: 2026 absolute_error_margin: 0.01 input: @@ -487,6 +495,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. period: 2026 absolute_error_margin: 0.01 input: @@ -589,3 +601,84 @@ # Each claim covers half the £1,800; neither has income above its # allowance. merton_council_tax_reduction: [900, 554.20, 0] + +- name: A disabled pensioner head who applies alone gets no working-age disability premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # The pension-age schedule has no adult disability premium, and its + # enhanced disability premium is for a child or young person (SI 2012/2885 + # Sch 2 Part 3, paras 6-9). + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + +- name: A pensioner head who applies alone and receives Attendance Allowance gets the pension-age severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The single severe disability premium only: 86.05 * 52 = 4,474.60. The + # parents are 17, so not non-dependants who bar it. + council_tax_reduction_premiums: [4_474.60] + council_tax_reduction_applicable_amount: [17_786.60] From 8b6d819bedcacb257d08dfd04d00105226d257b4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:56:44 -0400 Subject: [PATCH 031/224] Exclude Pension Credit families from the liable-head income invariant main's pensioner schemes disregard a guarantee credit recipient's whole income and use Pension Credit's assessment in savings-credit-only cases (SI 2012/2885 Sch 1 paras 13-14), so raising the head's pension need not raise their applicable income there. Co-Authored-By: Claude Opus 5.5 --- ...ncil_tax_reduction_applicant_properties.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 565a69e74f..82450619df 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -18,9 +18,10 @@ Allowance and blindness; all eight modelled schemes. 1. Head's income counts: raising the liable household head's own private - pension by £5,000 raises their family's applicable income. Raising a - young parent's earnings by £5,000 leaves the family's applicable income - unchanged where the head applies alone. + pension by £5,000 raises their family's applicable income, unless the + family is on Pension Credit, whose assessment the pensioner schemes use + instead. Raising a young parent's earnings by £5,000 leaves the family's + applicable income unchanged where the head applies alone. 2. Monotone: raising the head's pension never raises any family's simulated reduction. 3. High income: with £250,000 more pension, the head's family gets no @@ -236,9 +237,17 @@ def family(variable, variant): head_alone = family("council_tax_reduction_head_applies_alone", "base") # 1. The head's pension counts; a young parent's earnings do not where - # the head applies alone. + # the head applies alone. A family on Pension Credit is assessed through + # Pension Credit instead (guarantee credit: its whole income disregarded; + # savings credit only: Pension Credit's assessment of income; SI 2012/2885 + # Sch 1 paras 13-14), so it is outside the first part. income = family("council_tax_reduction_applicable_income", "base") - assert (family("council_tax_reduction_applicable_income", "pension") > income).all() + on_pension_credit = np.zeros(len(population), dtype=bool) + for variant in ["base", "pension"]: + on_pension_credit |= family("in_receipt_of_guarantee_credit", variant) + on_pension_credit |= family("in_receipt_of_savings_credit_only", variant) + raised = family("council_tax_reduction_applicable_income", "pension") + assert (raised > income)[~on_pension_credit].all() parent_income = family("council_tax_reduction_applicable_income", "parent_earnings") assert np.allclose(parent_income[head_alone], income[head_alone]) From bcdee62b32a205c910e55276c6e0d2e082cfd74d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:41:13 -0400 Subject: [PATCH 032/224] 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 7e2c1f0d87752dfcf89661a8800e017b72191bf3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:09:07 -0400 Subject: [PATCH 033/224] Pin the higher pension-age rates to the statute; #1925 review comment fixes - test_housing_benefit_pension_age_cohorts: STATUTE_HIGHER_RATES holds SI 2006/214 Sch 3 para 1(1)(b)/(2)(b) for 2021-22 to 2026-27, now that #1925 set them to the published amounts, so both rates are hard-coded from law. - From #1925's review: the rates fixture header and test docstring say 2026-27 has only its own edition so far (and name UC's 2021-22 exception), and a CTR test comment shows 7,199.40 and 1,173.56. Co-Authored-By: Claude Opus 5.5 --- .../dwp_benefit_and_pension_rates.yaml | 18 ++++++------ ...l_tax_reduction_carer_support_payment.yaml | 2 +- .../tests/test_dwp_published_rates.py | 9 ++++-- ...est_housing_benefit_pension_age_cohorts.py | 29 +++++++++++++++++-- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml b/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml index 37cf87bfda..80e5b9fd16 100644 --- a/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml +++ b/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml @@ -1,15 +1,15 @@ # DWP "Benefit and pension rates" (and HMRC tax credit rates), by fiscal year -# (2025 = 2025-26), in each parameter's own unit and period. Each year's rate -# appears in that year's publication and as the previous-year column of the -# next year's, and the two agree, except UC's 2021-22 amounts: the temporary +# (2025 = 2025-26), in each parameter's own unit and period. Each year's rate to +# 2025-26 appears in that year's publication and as the previous-year column of +# the next year's, and the two agree, except UC's 2021-22 amounts: the temporary # uplift ended in October 2021, and the 2022-23 edition's previous-year column # gives the later amounts. The fixture holds the April 2021 amounts from the -# 2021-22 edition, which PolicyEngine reads at 30 April. For 2025-26 and -# 2026-27 the amounts match the -# Social Security Benefits Up-rating Orders 2025 (SI 2025/295) and 2026 (SI -# 2026/148), except the 2026-27 income-related ESA personal allowances, which -# are set under the Universal Credit Act 2025 s.5. Rows marked `derived` are -# computed from published rows as stated. Read by test_dwp_published_rates.py. +# 2021-22 edition, which PolicyEngine reads at 30 April. 2026-27 has only its +# own edition so far. For 2025-26 and 2026-27 the amounts match the Social +# Security Benefits Up-rating Orders 2025 (SI 2025/295) and 2026 (SI 2026/148), +# except the 2026-27 income-related ESA personal allowances, which are set under +# the Universal Credit Act 2025 s.5. Rows marked `derived` are computed from +# published rows as stated. Read by test_dwp_published_rates.py. sources: 2015: https://www.gov.uk/government/publications/proposed-benefit-and-pension-rates-2015-to-2016 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index c944aaf56c..f18404306e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -217,7 +217,7 @@ scottish_carer_supplement: 0 council_tax_reduction_applicable_income: 7_000 + 83.30 * 52 council_tax_reduction_applicable_amount: (92.05 + 46.40) * 52 - # 2,000 - 0.2 x (11,331.60 - 7,198.88) = 1,173.46 + # 2,000 - 0.2 x (11,331.60 - 7,199.40) = 1,173.56 council_tax_reduction: 2_000 - 0.2 * (7_000 + 83.30 * 52 - (92.05 + 46.40) * 52) - name: In 2024 the Scottish carer is still on Carer's Allowance, which was already counted diff --git a/policyengine_uk/tests/test_dwp_published_rates.py b/policyengine_uk/tests/test_dwp_published_rates.py index f4c60567ca..3d52246ff1 100644 --- a/policyengine_uk/tests/test_dwp_published_rates.py +++ b/policyengine_uk/tests/test_dwp_published_rates.py @@ -2,9 +2,12 @@ The fixture fixtures/dwp_benefit_and_pension_rates.yaml lists, for each parameter with a published counterpart, the rate for each fiscal year as -published by DWP (and HMRC for tax credits). Each year appears in two -documents, that year's and the next year's, and the fixture holds the value -both agree on. Values are in the parameter's own unit and period. +published by DWP (and HMRC for tax credits). Each year to 2025-26 appears in +two documents, that year's and the next year's, and the fixture holds the +value both agree on; for UC's 2021-22 amounts, which changed in October 2021, +it holds the April amounts. 2026-27 has only its own edition so far and is +checked against SI 2026/148. Values are in the parameter's own unit and +period. Invariants: diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 78378d30ba..42e1d54d7c 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -22,8 +22,9 @@ earlier date of birth never gives a lower allowance. 4. Before April 2021 there was one pension-age rate, which every pension-age unit gets. -5. The lower rates and the cutoff equal the statute's table for every year - 2021-22 to 2026-27 (hard-coded below from legislation.gov.uk). +5. The lower rates, the higher rates and the cutoff equal the statute's table + for every year 2021-22 to 2026-27 (hard-coded below from legislation.gov.uk + and DWP's Benefit and pension rates). 6. Council Tax Reduction: England's pensioner scheme has the same split and amounts (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29), so its allowance equals Housing Benefit's; the Scottish and Welsh schemes have @@ -222,3 +223,27 @@ def test_lower_rates_and_cutoff_match_the_statute(year): assert P.lone_parent.aged_from_cutoff(at) == pytest.approx(single, abs=1e-6) assert P.couple.aged_from_cutoff(at) == pytest.approx(couple, abs=1e-6) assert P.pension_age_cutoff(at) == 20210401 + + +# SI 2006/214 Sch 3 para 1(1)(b) and (2)(b), weekly, for those who attained State +# Pension age before 1 April 2021, set by the Up-rating Orders SI 2021/162, +# 2022/292, 2023/316, 2024/242, 2025/295 and 2026/148; the same amounts are in +# DWP's Benefit and pension rates for each year. +STATUTE_HIGHER_RATES = { + 2021: (191.15, 286.05), + 2022: (197.10, 294.90), + 2023: (217.00, 324.70), + 2024: (235.20, 352.00), + 2025: (244.40, 366.00), + 2026: (256.00, 383.35), +} + + +@pytest.mark.parametrize("year", sorted(STATUTE_HIGHER_RATES)) +def test_higher_rates_match_the_statute(year): + """Invariant 5.""" + single, couple = STATUTE_HIGHER_RATES[year] + at = f"{year}-06-01" + assert P.single.aged(at) == pytest.approx(single, abs=1e-6) + assert P.lone_parent.aged(at) == pytest.approx(single, abs=1e-6) + assert P.couple.aged(at) == pytest.approx(couple, abs=1e-6) From 368ebb0618e7e2e43f8280b94a3ed15c20f7eb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:00:47 -0400 Subject: [PATCH 034/224] 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 035/224] 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 036/224] 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 037/224] 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 038/224] 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 039/224] 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 040/224] 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 041/224] 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 a182e2fc80ef2a1c96232ef4a0d666971b95c224 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 15:28:14 +0100 Subject: [PATCH 042/224] Test that Scenario reforms keep the baseline parameter tree unreformed (#2188) Main's dated-reform rework (3edfb4b99) now keeps parameters.baseline as current law when Simulation.apply_parameter_changes reprocesses the tree, which is what this commit originally changed in simulation.py and tax_benefit_system.py. On rebase those hunks give way to main's version. These regression tests stay: baseline_vat scores a VAT change against the unreformed rate on every reform route, the baseline tree matches an unreformed system for several parameters, and adjust_budgets sees a Scenario spending change. Co-Authored-By: Claude Opus 5.5 --- .../test_scenario_baseline_parameters.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 policyengine_uk/tests/test_scenario_baseline_parameters.py diff --git a/policyengine_uk/tests/test_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py new file mode 100644 index 0000000000..2319ec002d --- /dev/null +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -0,0 +1,100 @@ +"""A Scenario's parameter changes must not leak into ``parameters.baseline``. + +``Simulation.apply_parameter_changes`` reloads and reprocesses the parameter +tree. Before #2188 it cloned the baseline child from the already reformed +tree, so ``baseline_vat`` and ``adjust_budgets`` compared a reform with +itself. +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.reforms.policyengine.adjust_budgets import adjust_budgets +from policyengine_uk.system import system + +VAT = "gov.hmrc.vat.standard_rate" + +SITUATION = { + "people": {"adult": {"age": {2026: 40}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "full_rate_vat_consumption": {2026: 10_000}, + "reduced_rate_vat_consumption": {2026: 0}, + } + }, +} + + +def _vat_change(sim): + return float(sim.calculate("vat", 2026)[0] - sim.calculate("baseline_vat", 2026)[0]) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"scenario": Scenario(parameter_changes={VAT: 0.22})}, + {"scenario": Scenario(parameter_changes={VAT: {"2026": 0.22}})}, + {"reform": {VAT: 0.22}}, + { + "reform": Reform.from_dict( + {VAT: {"2000-01-01.2100-12-31": 0.22}}, country_id="uk" + ) + }, + ], + ids=["scenario-scalar", "scenario-year", "reform-dict", "core-reform"], +) +def test_every_reform_route_scores_vat_against_the_unreformed_baseline(kwargs): + sim = Simulation(situation=SITUATION, **kwargs) + assert _vat_change(sim) == pytest.approx(526.32, abs=0.01) + assert sim.tax_benefit_system.parameters.baseline.gov.hmrc.vat.standard_rate( + "2026-06-01" + ) == pytest.approx(0.2) + + +def test_no_reform_has_no_vat_change(): + assert _vat_change(Simulation(situation=SITUATION)) == 0 + + +@pytest.mark.parametrize( + "path, instant", + [ + (VAT, "2026-06-01"), + ("gov.hmrc.income_tax.allowances.personal_allowance.amount", "2028-06-01"), + ("gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", "2029-06-01"), + ("gov.hmrc.fuel_duty.petrol_and_diesel", "2027-02-01"), + ("gov.dfe.education_spending", "2027-01-01"), + ], +) +def test_scenario_leaves_the_baseline_tree_identical_to_an_unreformed_system( + path, instant +): + changes = { + VAT: 0.25, + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": 500, + "gov.hmrc.fuel_duty.petrol_and_diesel": 0.70, + "gov.dfe.education_spending": 150, + } + sim = Simulation(situation=SITUATION, scenario=Scenario(parameter_changes=changes)) + reformed = sim.tax_benefit_system.parameters + unreformed = system.parameters + assert reformed.baseline.get_child(path)(instant) == pytest.approx( + unreformed.baseline.get_child(path)(instant) + ) + # The reform itself still reaches the processed tree. + assert reformed.get_child(path)(instant) != pytest.approx( + unreformed.get_child(path)(instant) + ) + + +def test_adjust_budgets_sees_a_scenario_spending_change(): + sim = Simulation( + situation=SITUATION, + scenario=Scenario(parameter_changes={"gov.dfe.education_spending": 150}), + ) + assert adjust_budgets(sim.tax_benefit_system.parameters, 2026) is not None + assert adjust_budgets(system.parameters, 2026) is None From 27df77c5056d769edede6aa3c675c12f0c9b7759 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 15:35:35 +0100 Subject: [PATCH 043/224] Derive the expected VAT change from the unreformed VAT, not a fixed amount The grossing factor differs between main and the VAT coverage branch, so the test now checks the 20% to 22% change against a tenth of the unreformed VAT. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_scenario_baseline_parameters.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py index 2319ec002d..5e1950fbd3 100644 --- a/policyengine_uk/tests/test_scenario_baseline_parameters.py +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -49,7 +49,12 @@ def _vat_change(sim): ) def test_every_reform_route_scores_vat_against_the_unreformed_baseline(kwargs): sim = Simulation(situation=SITUATION, **kwargs) - assert _vat_change(sim) == pytest.approx(526.32, abs=0.01) + # All spending here is standard-rated, so raising the rate from 20% to + # 22% raises VAT by a tenth, whatever the grossing factor (526.32 at the + # current factor). + unreformed_vat = float(Simulation(situation=SITUATION).calculate("vat", 2026)[0]) + assert _vat_change(sim) == pytest.approx(0.1 * unreformed_vat, abs=0.01) + assert _vat_change(sim) > 0 assert sim.tax_benefit_system.parameters.baseline.gov.hmrc.vat.standard_rate( "2026-06-01" ) == pytest.approx(0.2) From 7f43df920ecb3e79dc581f4b66b2cd84c20d4072 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 9 Oct 2026 18:33:00 +0100 Subject: [PATCH 044/224] Let structural reforms read their simulation (review C1) Fixes juaristi22's critical finding C1 on #2193: a before-load Scenario(parameter_changes={"gov.dfe.education_spending": 150}, applied_before_data_load=True) stopped Simulation and Microsimulation from constructing with "CountryTaxBenefitSystem object has no attribute simulation" at adjust_budgets.py:60. Core's Simulation.apply_reform calls reform.apply(tax_benefit_system), and core's __init__ binds tax_benefit_system.simulation first. The UK Simulation does not call core's __init__, so the binding never existed and adjust_budgets / disable_simulated_benefits could never run. With the baseline tree now unreformed, the budget reform is registered on this route for the first time. - Simulation.apply_reform binds tax_benefit_system.simulation = self on every call (clones bind to themselves). - adjust_budget reads every year's allocation before writing any, so a written year carried over to later years no longer stacks its change onto theirs, and skips years with no allocation instead of writing NaN. - Regression tests: the reviewer's repro constructing both classes with finite net income, the expected DfE allocation per year 2026-29, the reform-simulation binding on a clone, and a dataset Microsimulation check marked microsimulation. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2188.fixed.md | 1 + .../reforms/policyengine/adjust_budgets.py | 19 +++- policyengine_uk/simulation.py | 12 ++ .../test_scenario_baseline_parameters.py | 106 +++++++++++++++++- 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2188.fixed.md diff --git a/changelog.d/2188.fixed.md b/changelog.d/2188.fixed.md new file mode 100644 index 0000000000..ae0863c450 --- /dev/null +++ b/changelog.d/2188.fixed.md @@ -0,0 +1 @@ +Structural reforms applied by a UK `Simulation` can now read the simulation they adjust, so a before-load `Scenario` change to DfE, DHSC or DfT spending constructs and scales each year's allocation without carrying an earlier year's change forward. diff --git a/policyengine_uk/reforms/policyengine/adjust_budgets.py b/policyengine_uk/reforms/policyengine/adjust_budgets.py index e010c853c4..4920c34536 100644 --- a/policyengine_uk/reforms/policyengine/adjust_budgets.py +++ b/policyengine_uk/reforms/policyengine/adjust_budgets.py @@ -59,9 +59,24 @@ class adjust_budget_reform(Reform): def apply(self): simulation = self.simulation + # Read every year's allocation before writing any. Each write is + # an input that carries over to later years without their own + # input, so reading a later year after writing an earlier one + # would stack the earlier year's change on top of its own. + spending_by_year = { + time_period: simulation.calculate(budget_variable, time_period) + for time_period in change_by_year + } + for time_period, budget_change in change_by_year.items(): - spending = simulation.calculate(budget_variable, time_period) - relative_increase = budget_change / (spending.sum() / 1e9) + spending = spending_by_year[time_period] + total_spending = spending.sum() / 1e9 + if total_spending == 0: + # Nothing is allocated in this year, so there is no + # allocation to scale. Dividing by zero would write NaN + # (rejected by set_input) or infinite spending. + continue + relative_increase = budget_change / total_spending simulation.set_input( budget_variable, diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 056279e927..81b842da28 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -255,6 +255,18 @@ def __init__( if scenario.simulation_modifier is not None: scenario.simulation_modifier(self) + def apply_reform(self, reform) -> None: + # policyengine-core's Simulation.apply_reform calls + # ``reform.apply(self.tax_benefit_system)``, and core's + # Simulation.__init__ binds ``tax_benefit_system.simulation`` before + # it applies reforms. This class does not call core's __init__, so + # make the binding here. Structural reforms that adjust inputs + # (adjust_budgets, disable_simulated_benefits) read it. Binding on + # every call also keeps a cloned system pointing at the simulation + # that applies the reform rather than the one it was cloned from. + self.tax_benefit_system.simulation = self + super().apply_reform(reform) + def delete_arrays(self, variable: str, period: Period = None) -> None: super().delete_arrays(variable, period) # From policyengine-core 3.32.27 (policyengine-core#561) the deletion diff --git a/policyengine_uk/tests/test_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py index 5e1950fbd3..1e272b0810 100644 --- a/policyengine_uk/tests/test_scenario_baseline_parameters.py +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -6,10 +6,11 @@ itself. """ +import numpy as np import pytest from policyengine_core.reforms import Reform -from policyengine_uk import Simulation +from policyengine_uk import Microsimulation, Simulation from policyengine_uk.model_api import Scenario from policyengine_uk.reforms.policyengine.adjust_budgets import adjust_budgets from policyengine_uk.system import system @@ -103,3 +104,106 @@ def test_adjust_budgets_sees_a_scenario_spending_change(): ) assert adjust_budgets(sim.tax_benefit_system.parameters, 2026) is not None assert adjust_budgets(system.parameters, 2026) is None + + +# Juaristi22's review of #2193 (C1): once the baseline tree stayed unreformed, +# a before-load Scenario spending change registered the budget adjustment, and +# Simulation and Microsimulation stopped constructing because the adjustment +# read ``self.simulation`` from a tax-benefit system that never had one. + +DFE = "gov.dfe.education_spending" +ALLOCATION = 1e9 # Synthetic £1bn allocation: routing only, not an estimate. + +BUDGET_SITUATION = { + "people": { + "adult": {"age": {2026: 40}, "employment_income": {2026: 30_000}}, + }, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "region": {2026: "LONDON"}, + "dfe_education_spending": {2026: ALLOCATION}, + # Fixed weights, so the Microsimulation's weighted total equals + # the allocation in every year. + "household_weight": {year: 1.0 for year in range(2023, 2030)}, + } + }, +} + +BEFORE_LOAD_DFE_150 = Scenario( + parameter_changes={DFE: 150}, applied_before_data_load=True +) + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_before_load_spending_scenario_constructs(simulation_class): + sim = simulation_class(situation=BUDGET_SITUATION, scenario=BEFORE_LOAD_DFE_150) + net_income = np.asarray(sim.calculate("household_net_income", 2026)) + assert np.isfinite(net_income).all() + assert np.isfinite( + np.asarray(sim.baseline.calculate("household_net_income", 2026)) + ).all() + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_before_load_spending_scenario_scales_the_allocation(simulation_class): + sim = simulation_class(situation=BUDGET_SITUATION, scenario=BEFORE_LOAD_DFE_150) + parameters = sim.tax_benefit_system.parameters + for year in range(2026, 2030): + baseline_budget = parameters.baseline.gov.dfe.education_spending(year) + assert parameters.gov.dfe.education_spending(year) == 150 + assert baseline_budget < 150 + # The allocation is scaled so its total rises by the budget change + # (in £bn) for each year on its own, without carrying an earlier + # year's change forward. + expected = ALLOCATION + (150 - baseline_budget) * 1e9 + reformed = np.asarray(sim.calculate("dfe_education_spending", year)) + assert reformed[0] == pytest.approx(expected, rel=1e-6) + baseline = np.asarray(sim.baseline.calculate("dfe_education_spending", year)) + assert baseline[0] == pytest.approx(ALLOCATION) + # 2026: £150bn against the unreformed £107.28bn. + assert np.asarray(sim.calculate("dfe_education_spending", 2026))[0] == ( + pytest.approx(43.7207e9, rel=1e-5) + ) + # No allocation before 2026, so there is nothing to scale there. + for year in range(2023, 2026): + assert np.asarray(sim.calculate("dfe_education_spending", year))[0] == 0 + # Other budgets are untouched. + for variable in ("nhs_spending", "dft_subsidy_spending"): + assert np.asarray(sim.calculate(variable, 2026))[0] == pytest.approx( + np.asarray(sim.baseline.calculate(variable, 2026))[0] + ) + + +def test_structural_reforms_can_read_the_simulation_they_are_applied_to(): + # adjust_budgets and disable_simulated_benefits read ``self.simulation`` + # inside Reform.apply, where ``self`` is the tax-benefit system. + seen = [] + + class reads_simulation(Reform): + def apply(self): + seen.append(self.simulation) + + sim = Simulation(situation=SITUATION) + sim.apply_reform(reads_simulation) + clone = sim.clone() + clone.apply_reform(reads_simulation) + assert seen == [sim, clone] + + +@pytest.mark.microsimulation +def test_before_load_spending_scenario_constructs_on_the_dataset(): + sim = Microsimulation(scenario=BEFORE_LOAD_DFE_150) + year = 2026 + parameters = sim.tax_benefit_system.parameters + budget_change = 150 - parameters.baseline.gov.dfe.education_spending(year) + weights = np.asarray(sim.calculate("household_weight", year, unweighted=True)) + reformed = np.asarray(sim.calculate("dfe_education_spending", year)) + # ``sim.baseline`` is a plain Simulation, so weight its values here. + baseline = np.asarray(sim.baseline.calculate("dfe_education_spending", year)) + reformed_total = (reformed * weights).sum() / 1e9 + baseline_total = (baseline * weights).sum() / 1e9 + assert baseline_total > 0 + assert reformed_total == pytest.approx(baseline_total + budget_change, rel=1e-4) + assert np.isfinite(np.asarray(sim.calculate("household_net_income", year))).all() From 70e426c01768ceb08c379cd877154c0d6bc31819 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 12:04:06 +0200 Subject: [PATCH 045/224] 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 b8f11fba06d7fa2a81c59c553ca0a6777cf66141 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:44:00 +0100 Subject: [PATCH 046/224] Reconcile UC deduction and benefit cap components under the protected floor The protected minimum floor was applied only when computing universal_credit, so uc_deductions and benefit_cap_reduction reported their unfloored values: a floor of 0.85 with a 1,000 GBP benefit cap reduction and a 0.30 latent rate reported 960.34 + 1,000 GBP against a 720.25 GBP reduction actually applied. The floor now lives in the components. uc_maximum_reduction holds the allowance the floor leaves; uc_deductions is limited only where deductions alone exceed it, and the new uc_benefit_cap_reduction takes what remains. That ordering matches JRF's worked example, which preserves the deduction and eliminates the cap reduction, and it means the components sum to the entitlement forgone. benefit_cap_reduction stays gross because Housing Benefit uses it and the UC floor does not protect Housing Benefit, which also keeps it out of a dependency cycle. Also clamps the floor allowance at zero (a floor above 1 made it negative and added money to the award), gates uc_deductions on would_claim_uc for the dataset-imputed migration path, and corrects the 25% cap date to 30 April 2021 and the one-penny citation to Schedule 6 paragraph 3(1)(a) of SI 2013/380. Current-law results are unchanged: the floor is zero, so the allowance is infinite and both components pass through unaltered. Co-Authored-By: Claude Opus 5 (1M context) --- .../uc-deductions-floor-reconciliation.md | 5 + docs/book/validation/uc-deductions.md | 16 +++- .../dwp/universal_credit/deductions/cap.yaml | 4 +- .../deductions/minimum_payable.yaml | 6 +- .../deductions/protected_floor.yaml | 2 +- policyengine_uk/tests/test_uc_deductions.py | 94 ++++++++++++++++++- .../deductions/uc_deductions.py | 31 ++++-- .../deductions/uc_maximum_reduction.py | 35 +++++++ .../uc_benefit_cap_reduction.py | 36 +++++++ .../dwp/universal_credit/universal_credit.py | 30 ++---- 10 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 changelog.d/uc-deductions-floor-reconciliation.md create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py diff --git a/changelog.d/uc-deductions-floor-reconciliation.md b/changelog.d/uc-deductions-floor-reconciliation.md new file mode 100644 index 0000000000..b60d47e56b --- /dev/null +++ b/changelog.d/uc-deductions-floor-reconciliation.md @@ -0,0 +1,5 @@ +- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. +- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor, leaving `benefit_cap_reduction` gross for Housing Benefit. +- Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. +- Fixed Universal Credit deductions applying to benefit units that would not claim Universal Credit. +- Corrected the effective date of the 25% deductions cap to 30 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index 5d7d4fc4a4..c84be05510 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -12,12 +12,14 @@ The implementation separates **latent demand** from the **statutory cap**. `uc_l Enhanced FRS 2023/24 v1.40.3, against [DWP's Universal Credit deductions statistics](https://www.gov.uk/government/statistics/universal-credit-quarterly-statistics-29-april-2013-to-12-february-2026/universal-credit-deductions-statistics-march-2025-to-february-2026). +Shares below are of **all** UC households, not of those with deductions — the denominator both the model and DWP use. + | Statistic | Model | DWP published | |---|---|---| | Share of UC households with deductions | 47.0–47.1% | 46–47% | -| At-cap share, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | -| At-cap share, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | -| Above-cap share (last resort) | 1.8% | 2% | +| At-cap share of all UC households, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | +| At-cap share of all UC households, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | +| Above-cap share of all UC households (last resort) | 1.8% | 2% | | Mean monthly deduction, 25% regime | £66 | £67–68 | | Mean monthly deduction, 15% regime | £50 | £51–54 | | Mean annual gain, 25%→15% cap | £421 | £420 (gov.uk) | @@ -47,7 +49,11 @@ The latent distribution is observed in 2025 and applied to all model years as a ### The protected floor binds on last resort deductions -The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. It binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. +The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. The benefit cap reduction absorbs the floor first: `uc_deductions` is cut only where deductions alone still exceed the allowance. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions essentially never breach the floor on their own — only benefit cap reductions push past it. JRF's own worked example (standard allowance £92, deduction £14, benefit cap £59, floor £78) works out the same way: the £14 deduction survives in full and the £59 cap reduction goes. + +Where deductions alone do exceed the allowance, the floor binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. + +Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone. `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. ### Other assumptions @@ -75,7 +81,7 @@ The `protected_floor` lever limits combined deductions and benefit cap reduction {"gov.dwp.universal_credit.deductions.protected_floor": {"2026-01-01.2030-12-31": 0.85}} ``` -A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from. +A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because the cap reduction absorbs the floor first, where nearly all of the modelled effect lands. ## Where the assignment lives diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml index 9a62d98a81..7cc392ffbb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml @@ -1,8 +1,8 @@ -description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). +description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 30 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). values: 2015-04-01: 0.4 2019-10-01: 0.3 - 2021-04-01: 0.25 + 2021-04-30: 0.25 2025-04-30: 0.15 metadata: label: UC deductions cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml index 075b067fe6..47e5da327b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml @@ -1,4 +1,4 @@ -description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6 of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. +description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6, paragraph 3(1)(a) of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. values: 2015-04-01: 0.01 metadata: @@ -6,5 +6,5 @@ metadata: unit: currency-GBP period: month reference: - - title: SI 2013/380, Schedule 6 (deductions from benefit and direct payment to third parties) - href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6 + - title: SI 2013/380, Schedule 6, paragraph 3(1)(a) (deductions must leave at least one penny payable) + href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6/paragraph/3 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml index 50fbf3aca3..0da2c48b45 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml @@ -1,4 +1,4 @@ -description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The modeled floor binds on all deductions, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. +description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The benefit cap reduction absorbs the floor first (uc_benefit_cap_reduction), so deductions are limited only where they alone exceed the allowance - matching JRF's worked example, which preserves the deduction in full and eliminates the benefit cap reduction. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. values: 2015-04-01: 0 metadata: diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index ffab86dfe2..373d79894f 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -161,9 +161,9 @@ def test_protected_floor_inactive_when_reductions_stay_above_it(self): ) def test_protected_floor_binds_on_the_above_cap_excess(self): - # Latent demand of 30% is 15% cappable plus a 15% last resort excess - # that current law exempts from the cap, giving a 20% deduction rate - # under the 15% cap. The modeled floor limits combined reductions + # Latent demand of 30% is 25% cappable (cut to 15% by the cap) plus a + # 5% last resort excess that current law exempts from the cap, giving + # a 20% deduction rate. The modeled floor limits combined reductions # regardless of category, so it cuts into that excess. JRF's briefing # does not say whether their floor exempts last resort and child # maintenance deductions; this pins the modeling choice. @@ -186,6 +186,94 @@ def test_protected_floor_binds_on_the_above_cap_excess(self): 6_000 - 0.15 * sa ) + def test_floor_components_reconcile_with_the_reduction_applied(self): + # uc_deductions and uc_benefit_cap_reduction are what the award + # actually loses, so they must sum to the entitlement forgone. Before + # the floor was pushed into the components, they reported £960 and + # £1,000 against a £720 reduction actually applied. + situation = make_situation( + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + deductions = reform.calculate("uc_deductions", YEAR)[0] + cap_reduction = reform.calculate("uc_benefit_cap_reduction", YEAR)[0] + universal_credit = reform.calculate("universal_credit", YEAR)[0] + assert deductions + cap_reduction == pytest.approx(0.15 * sa) + assert deductions + cap_reduction == pytest.approx(6_000 - universal_credit) + # The gross benefit cap reduction is unchanged: it also drives + # Housing Benefit, which the Universal Credit floor does not protect. + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): + # JRF's worked example (standard allowance £92, deduction £14, + # benefit cap £59, floor £78) keeps the £14 deduction whole and + # eliminates the £59 cap reduction. Deductions of 10% of the standard + # allowance fit inside a 15% floor allowance, so they survive intact + # and the cap reduction takes the whole remaining squeeze. + situation = make_situation( + benefit_cap_reduction={YEAR: 2_500}, + uc_latent_deduction_rate={YEAR: 0.10}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.10 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == pytest.approx( + 0.05 * sa + ) + + def test_a_floor_above_the_standard_allowance_adds_nothing(self): + # (1 - floor) x standard allowance goes negative above a floor of 1; + # subtracting it added £960 to a £6,000 award. The allowance clamps + # at zero, where the floor protects the standard allowance entirely. + situation = make_situation( + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 1.2 + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx(6_000) + + def test_no_deductions_without_a_universal_credit_claim(self): + # Datasets are meant to impute uc_latent_deduction_rate directly, at + # which point nothing else gates deductions on the claim. + sim = Simulation( + situation=make_situation( + would_claim_uc={YEAR: False}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + ) + assert sim.calculate("universal_credit", YEAR)[0] == 0 + assert sim.calculate("uc_deductions", YEAR)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + def test_zeroing_every_type_share_leaves_a_valid_enum_array(self): situation = make_situation( uc_deduction_random_draw={YEAR: 0.0}, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py index 0ef750fec5..9cf7698b3e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py @@ -7,18 +7,20 @@ class uc_deductions(Variable): "Money deducted from this benefit unit's Universal Credit award to " "repay debts: advance repayments, government debt (benefit and tax " "credit overpayments) and third party deductions. Deductions leave " - "at least one penny per assessment period payable (Schedule 6 of " - "SI 2013/380). Per-household statistics are validated against the " - "DWP deductions statistics; weighted aggregates are not, because " - "they scale with the model's UC caseload, which falls short of the " - "administrative count. Annualized from the surveyed monthly rate: " - "for deduction types with spells shorter than a year (e.g. advance " - "recovery), annual per-household amounts are upper bounds." + "at least one penny per assessment period payable (Schedule 6, " + "paragraph 3(1)(a) of SI 2013/380). Per-household statistics are " + "validated against the DWP deductions statistics; weighted " + "aggregates are not, because they scale with the model's UC " + "caseload, which falls short of the administrative count. " + "Annualized from the surveyed monthly rate: for deduction types " + "with spells shorter than a year (e.g. advance recovery), annual " + "per-household amounts are upper bounds." ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + defined_for = "would_claim_uc" def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.deductions @@ -30,4 +32,17 @@ def formula(benunit, period, parameters): 0, ) minimum_payable = p.minimum_payable * MONTHS_IN_YEAR - return min_(rate * standard_allowance, max_(award - minimum_payable, 0)) + deductions = min_(rate * standard_allowance, max_(award - minimum_payable, 0)) + # The protected minimum floor (zero, and so inactive, under current + # law) limits deductions and benefit cap reductions combined. The + # benefit cap reduction absorbs it first, so deductions bend only + # where they alone exceed the floor allowance: under the Fair + # Repayment Rate the 15% deductions cap equals a typical 15% floor + # allowance, so cappable deductions essentially never breach the + # floor on their own. The floor does bind on the above-cap excess + # that current law exempts from the deductions cap (last resort and + # child maintenance deductions). JRF's briefing does not say whether + # their floor exempts those categories - its worked example involves + # only cappable deductions and the benefit cap - so this is a + # modeling choice, tracked for follow-up. + return min_(deductions, benunit("uc_maximum_reduction", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py new file mode 100644 index 0000000000..82e37fa76d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class uc_maximum_reduction(Variable): + label = "UC maximum combined reduction" + documentation = ( + "Most that benefit cap reductions and deductions combined may take " + "off a Universal Credit award under the protected minimum floor: " + "(1 - floor) x the standard allowance. Infinite when the floor is " + "zero - the value under current law, which has no such floor - so " + "reductions are unlimited." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + + def formula(benunit, period, parameters): + # A protected minimum floor (zero under current law) caps combined + # benefit cap reductions and deductions at (1 - floor) x the standard + # allowance, per JRF's protected minimum floor design (their worked + # example limits the reduction itself to 15% of the standard + # allowance). Zero means reductions are unlimited. + floor_rate = parameters( + period + ).gov.dwp.universal_credit.deductions.protected_floor + standard_allowance = benunit("uc_standard_allowance", period) + # A floor above 1 would make the allowance negative; subtracting a + # negative reduction would add money to the award. Clamp at zero, + # where the floor protects the whole standard allowance. + return where( + floor_rate > 0, + max_((1 - floor_rate) * standard_allowance, 0), + np.inf, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py new file mode 100644 index 0000000000..081dc56c67 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_reduction(Variable): + label = "UC benefit cap reduction" + documentation = ( + "Benefit cap reduction actually taken off this benefit unit's " + "Universal Credit award. Equal to benefit_cap_reduction under " + "current law. Where a protected minimum floor is in force, the cap " + "reduction absorbs the floor first, so only the part of it fitting " + "in the allowance uc_deductions leaves is applied. " + "benefit_cap_reduction itself stays gross, because it also applies " + "to Housing Benefit, which the Universal Credit floor does not " + "protect." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + defined_for = "would_claim_uc" + + def formula(benunit, period, parameters): + reduction = benunit("benefit_cap_reduction", period) + # Deductions take their share of the floor allowance first, so the + # cap reduction is what bends when the floor binds. Under the Fair + # Repayment Rate the 15% deductions cap equals a typical 15% floor + # allowance, so cappable deductions alone essentially never breach + # the floor - only benefit cap reductions push past it. JRF's worked + # example (standard allowance £92, deduction £14, benefit cap £59, + # floor £78) preserves the £14 deduction and eliminates the £59 cap + # reduction, which is this ordering. + headroom = max_( + benunit("uc_maximum_reduction", period) - benunit("uc_deductions", period), + 0, + ) + return min_(reduction, headroom) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index f30621334a..5230ba6324 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -11,26 +11,12 @@ class universal_credit(Variable): def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) - benefit_cap_reduction = benunit("benefit_cap_reduction", period) + # Both components are already limited by the protected minimum floor + # (zero, and so inactive, under current law): uc_deductions takes its + # share of the floor allowance first and uc_benefit_cap_reduction + # takes what is left. Subtracting them separately, rather than + # limiting their sum here, keeps the reported components summing to + # the reduction actually applied. + benefit_cap_reduction = benunit("uc_benefit_cap_reduction", period) deductions = benunit("uc_deductions", period) - # A protected minimum floor (zero under current law) caps combined - # benefit cap reductions and deductions at (1 - floor) x the standard - # allowance, per JRF's protected minimum floor design (their worked - # example limits the reduction itself to 15% of the standard - # allowance). Zero means reductions are unlimited. - # The floor binds on the whole of uc_deductions, including the above- - # cap excess that current law exempts from the deductions cap (last - # resort and child maintenance deductions). JRF's briefing does not - # say whether their floor exempts those categories - its worked - # example involves only cappable deductions and the benefit cap - so - # this is a modeling choice, tracked for follow-up. - floor_rate = parameters( - period - ).gov.dwp.universal_credit.deductions.protected_floor - total_reductions = benefit_cap_reduction + deductions - max_reductions = where( - floor_rate > 0, - (1 - floor_rate) * benunit("uc_standard_allowance", period), - np.inf, - ) - return max_(uc_max_entitlement - min_(total_reductions, max_reductions), 0) + return max_(uc_max_entitlement - benefit_cap_reduction - deductions, 0) From d4396aca225ebd7ebaa6fb77da52823fd4d672fc Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:45:07 +0100 Subject: [PATCH 047/224] Cite Schedule 6 paragraph 3(1)(a) in the one-penny policy test comment Co-Authored-By: Claude Opus 5 (1M context) --- .../finance/benefit/family/universal_credit/uc_deductions.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml index 1840c13910..23e9f370d2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml @@ -107,7 +107,8 @@ household: members: person output: - # Schedule 6 of SI 2013/380: the award cannot be deducted below one + # Schedule 6, paragraph 3(1)(a) of SI 2013/380: the award cannot be + # deducted below one # penny per assessment period (12p per year). uc_deductions: 499.88 universal_credit: 0.12 From 3753e83eebd76c2c3aa48addf704da301f0ab110 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 08:22:05 +0200 Subject: [PATCH 048/224] Give the changelog fragment a towncrier type Co-Authored-By: Claude Opus 5.5 --- ...onciliation.md => uc-deductions-floor-reconciliation.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{uc-deductions-floor-reconciliation.md => uc-deductions-floor-reconciliation.fixed.md} (100%) diff --git a/changelog.d/uc-deductions-floor-reconciliation.md b/changelog.d/uc-deductions-floor-reconciliation.fixed.md similarity index 100% rename from changelog.d/uc-deductions-floor-reconciliation.md rename to changelog.d/uc-deductions-floor-reconciliation.fixed.md From 7ab0afa3325fc78d040be74f6106237c667583a7 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:56:09 +0200 Subject: [PATCH 049/224] 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 050/224] 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 051/224] 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 c9f01a96df2e8ce25664e15bd6b03d526ced0586 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:50:10 +0400 Subject: [PATCH 052/224] Fix TV licence liability trigger --- changelog.d/2145.fixed.md | 1 + .../bbc/tv-licence/free_tv_licence_value.yaml | 19 ++++++++++++ .../gov/dcms/bbc/tv-licence/tv_licence.yaml | 29 +++++++++++++++++++ .../bbc/tv_licence/free_tv_licence_value.py | 8 +++-- .../gov/dcms/bbc/tv_licence/tv_licence.py | 9 ++++-- .../bbc/tv_licence/tv_licence_required.py | 20 +++++++++++++ 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2145.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml create mode 100644 policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py diff --git a/changelog.d/2145.fixed.md b/changelog.d/2145.fixed.md new file mode 100644 index 0000000000..d92d4d0229 --- /dev/null +++ b/changelog.d/2145.fixed.md @@ -0,0 +1 @@ +- Based TV licence charges on licensable viewing rather than television ownership alone. diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml new file mode 100644 index 0000000000..8566b086ea --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -0,0 +1,19 @@ +- name: Free licence value applies to BBC iPlayer use without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Television ownership alone creates no free licence value. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml index 4b62332a7c..44c2f0b536 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml @@ -42,3 +42,32 @@ would_evade_tv_licence_fee: false output: tv_licence: 0 + +- name: TV licence required for BBC iPlayer without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 180 + +- name: Television ownership alone does not require a TV licence. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 + +- name: Television ownership remains the fallback for existing inputs. + period: 2026 + input: + household_owns_tv: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 6626fc9842..576d0255ec 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -8,13 +8,15 @@ class free_tv_licence_value(Variable): value_type = float unit = "/1" reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", - "https://www.tvlicensing.co.uk/reducedfee", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + "https://www.gov.uk/free-discount-tv-licence", ) def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) + licence_required = household("tv_licence_required", period) discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * discount + return (licence_required & ~would_evade) * fee * discount diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py index ef0fb57f54..ad6c0eb9e7 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py @@ -8,12 +8,15 @@ class tv_licence(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2003/21/section/363" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) category = TAX def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) + licence_required = household("tv_licence_required", period) discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * (1 - discount) + return (licence_required & ~would_evade) * fee * (1 - discount) diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py new file mode 100644 index 0000000000..da64969f6a --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class tv_licence_required(Variable): + label = "TV licence required" + documentation = ( + "Whether anyone in the household watches or records live television " + "or uses BBC iPlayer. When this is not provided directly, television " + "ownership is used as a backward-compatible proxy." + ) + entity = Household + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) + + def formula(household, period, parameters): + return household("household_owns_tv", period) From 3791ca2e319bdaa9e0158e0018cd2500052554ec Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:20:03 +0400 Subject: [PATCH 053/224] Correct free TV licence HBAI value --- changelog.d/2145.fixed.md | 2 +- .../bbc/tv-licence/free_tv_licence_value.yaml | 22 +++++++++++++++++-- .../tests/test_tv_licence_metadata.py | 8 +++++++ .../bbc/tv_licence/free_tv_licence_value.py | 7 ++++-- 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 policyengine_uk/tests/test_tv_licence_metadata.py diff --git a/changelog.d/2145.fixed.md b/changelog.d/2145.fixed.md index d92d4d0229..8552054381 100644 --- a/changelog.d/2145.fixed.md +++ b/changelog.d/2145.fixed.md @@ -1 +1 @@ -- Based TV licence charges on licensable viewing rather than television ownership alone. +- Based TV licence charges on licensable viewing and corrected the HBAI free-licence value. diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml index 8566b086ea..486c8774f6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -1,4 +1,4 @@ -- name: Free licence value applies to BBC iPlayer use without television ownership. +- name: Case 1, BBC iPlayer use without television ownership. period: 2026 input: household_owns_tv: false @@ -8,7 +8,7 @@ output: free_tv_licence_value: 180 -- name: Television ownership alone creates no free licence value. +- name: Case 2, television ownership without licensable use. period: 2026 input: household_owns_tv: true @@ -17,3 +17,21 @@ would_evade_tv_licence_fee: false output: free_tv_licence_value: 0 + +- name: Case 3, full free licence value enters HBAI income in kind. + period: 2026 + input: + tv_licence_required: true + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Case 4, blind discount is not a free licence in HBAI income. + period: 2026 + input: + tv_licence_required: true + tv_licence_discount: 0.5 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 0 diff --git a/policyengine_uk/tests/test_tv_licence_metadata.py b/policyengine_uk/tests/test_tv_licence_metadata.py new file mode 100644 index 0000000000..6c90645b81 --- /dev/null +++ b/policyengine_uk/tests/test_tv_licence_metadata.py @@ -0,0 +1,8 @@ +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.model_api import GBP + + +def test_free_tv_licence_value_uses_currency_units(): + system = CountryTaxBenefitSystem() + + assert system.variables["free_tv_licence_value"].unit == GBP diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 576d0255ec..cf7760399b 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -6,12 +6,14 @@ class free_tv_licence_value(Variable): entity = Household definition_period = YEAR value_type = float - unit = "/1" + unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2003/21/section/363", "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + "https://www.legislation.gov.uk/uksi/2026/104/made", "https://www.gov.uk/free-discount-tv-licence", + "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025", ) def formula(household, period, parameters): @@ -19,4 +21,5 @@ def formula(household, period, parameters): discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (licence_required & ~would_evade) * fee * discount + receives_free_licence = discount == 1 + return (licence_required & ~would_evade & receives_free_licence) * fee From a99deaa5f787a16175296db0814a7b35e4068c76 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:04:50 +0400 Subject: [PATCH 054/224] Fix issues from review: split free TV licence share --- .../bbc/tv-licence/free_tv_licence_value.yaml | 70 ++++++++++++++++++- .../gov/dcms/bbc/tv-licence/tv_licence.yaml | 12 ++++ .../bbc/tv_licence/free_tv_licence_value.py | 12 +++- .../bbc/tv_licence/tv_licence_discount.py | 21 ++---- .../tv_licence_free_licence_share.py | 34 +++++++++ .../bbc/tv_licence/tv_licence_required.py | 4 +- .../tv_licence/would_evade_tv_licence_fee.py | 3 +- 7 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml index 486c8774f6..f1dbfc9544 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -3,7 +3,7 @@ input: household_owns_tv: false tv_licence_required: true - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 180 @@ -13,7 +13,7 @@ input: household_owns_tv: true tv_licence_required: false - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 0 @@ -22,7 +22,7 @@ period: 2026 input: tv_licence_required: true - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 180 @@ -31,7 +31,71 @@ period: 2026 input: tv_licence_required: true + tv_licence_free_licence_share: 0 + is_blind: true + would_evade_tv_licence_fee: false + output: tv_licence_discount: 0.5 + free_tv_licence_value: 0 + +- name: Case 5, a partial-year free licence contributes its proportional value. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 would_evade_tv_licence_fee: false output: + free_tv_licence_value: 50.49 + +- name: Case 6, an over-75 household on Pension Credit gets a full free licence. + period: 2026 + input: + age: 80 + pension_credit: 1 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 1 + free_tv_licence_value: 180 + +- name: Case 7, age alone does not qualify after the Pension Credit condition begins. + period: 2026 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0 free_tv_licence_value: 0 + +- name: Case 8, the 2020 transition determines both free value and paid liability. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: false + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.3205479452 + free_tv_licence_value: 50.49 + tv_licence: 107.01 + +- name: Case 9, the blind discount reduces only the paid part of the 2020 licence. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.6602739726 + free_tv_licence_value: 50.49 + tv_licence: 53.51 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml index 44c2f0b536..7a33fb64d0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml @@ -71,3 +71,15 @@ would_evade_tv_licence_fee: false output: tv_licence: 0 + +- name: Blind discount applies only after a partial-year free licence ends. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_discount: 0.6602739726 + tv_licence: 53.51 diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index cf7760399b..9e725b692d 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -3,6 +3,10 @@ class free_tv_licence_value(Variable): label = "free TV licence value" + documentation = ( + "Value included in HBAI income for the share of the year covered by " + "the complete age-based exemption. The blind discount is excluded." + ) entity = Household definition_period = YEAR value_type = float @@ -18,8 +22,10 @@ class free_tv_licence_value(Variable): def formula(household, period, parameters): licence_required = household("tv_licence_required", period) - discount = household("tv_licence_discount", period) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - receives_free_licence = discount == 1 - return (licence_required & ~would_evade & receives_free_licence) * fee + return (licence_required & ~would_evade) * fee * free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py index 655b1eca18..c220d08179 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py @@ -1,5 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.excise import fiscal_year_segments class tv_licence_discount(Variable): @@ -17,24 +16,14 @@ class tv_licence_discount(Variable): def formula(household, period, parameters): person = household.members tv_licence = parameters(period).gov.dcms.bbc.tv_licence - - # Aged discount - claims_pc = add(household, period, ["pension_credit"]) > 0 - aged_discount = 0 - for aged_rules, share in fiscal_year_segments( - parameters.gov.dcms.bbc.tv_licence.discount.aged, - period.start.year, - ): - aged = person("age", period) >= aged_rules.min_age - has_aged = household.any(aged) - meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc - aged_discount += ( - has_aged * meets_pc_requirement * aged_rules.discount * share - ) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) # Blind discount is_blind = person("is_blind", period) has_blind = household.any(is_blind) blind_discount = has_blind * tv_licence.discount.blind.discount - return max_(aged_discount, blind_discount) + return free_licence_share + (1 - free_licence_share) * blind_discount diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py new file mode 100644 index 0000000000..5be64444cc --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.excise import fiscal_year_segments + + +class tv_licence_free_licence_share(Variable): + label = "Share of the year covered by a free TV licence" + documentation = ( + "Share of the year for which the household qualifies for the complete " + "age-based TV licence exemption. This excludes the blind discount." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", + "https://www.gov.uk/government/news/bbc-and-governments-joint-statement-on-delaying-the-tv-licence-fee-for-over-75s", + "https://www.gov.uk/free-discount-tv-licence", + ) + + def formula(household, period, parameters): + person = household.members + claims_pc = add(household, period, ["pension_credit"]) > 0 + free_licence_share = 0 + for aged_rules, share in fiscal_year_segments( + parameters.gov.dcms.bbc.tv_licence.discount.aged, + period.start.year, + ): + has_aged = household.any(person("age", period) >= aged_rules.min_age) + meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc + free_licence_share += ( + has_aged * meets_pc_requirement * aged_rules.discount * share + ) + return free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py index da64969f6a..f7acf05b1d 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py @@ -6,7 +6,9 @@ class tv_licence_required(Variable): documentation = ( "Whether anyone in the household watches or records live television " "or uses BBC iPlayer. When this is not provided directly, television " - "ownership is used as a backward-compatible proxy." + "ownership is used as a backward-compatible proxy. That proxy can " + "overstate liability for owners who use only non-BBC on-demand services " + "and understate it for non-owners who watch live television or BBC iPlayer." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py index 61be081395..7239d88489 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py @@ -5,7 +5,8 @@ class would_evade_tv_licence_fee(Variable): label = "Would evade TV licence fee" documentation = ( "Whether this household would unlawfully evade the TV licence fee. " - "Generated stochastically in the dataset using evasion rates." + "Generated stochastically in the dataset using evasion rates. The " + "realised result does not change in response to reforms of the fee." ) entity = Household definition_period = YEAR From 656a0f8203a084c9a418123e3671e846ffe38832 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:47:59 +0200 Subject: [PATCH 055/224] Allocate UC deductions and the applied benefit cap through one helper under the protected floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The applied UC cap reduction is bounded by the award actually lost (a £1,000 gross reduction on a £500 award applied £720.25 under an 85% floor, and £1,000 with none). Where the floor binds, deductions are met first from the protected award, up to the floor allowance, and the cap reduction absorbs the rest; previously the penny rule squeezed deductions under the gross cap, so heavy capping moved the split. Where the floor does not bind, and under current law, values are unchanged and universal_credit keeps main's float order. The 25% deductions cap dates from 12 April 2021 (DWP Annual Report 2020-21). The JRF reference moves to the September 2026 briefing, with a test of its example. Co-Authored-By: Claude Opus 5.5 --- ...c-deductions-floor-reconciliation.fixed.md | 6 +- docs/book/validation/uc-deductions.md | 8 +- .../dwp/universal_credit/deductions/cap.yaml | 6 +- .../deductions/protected_floor.yaml | 6 +- policyengine_uk/tests/test_uc_deductions.py | 96 ++++++++++++++++++- .../deductions/_reduction_allocation.py | 61 ++++++++++++ .../deductions/uc_deductions.py | 37 ++++--- .../uc_benefit_cap_reduction.py | 46 +++++---- .../dwp/universal_credit/universal_credit.py | 13 ++- 9 files changed, 216 insertions(+), 63 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py diff --git a/changelog.d/uc-deductions-floor-reconciliation.fixed.md b/changelog.d/uc-deductions-floor-reconciliation.fixed.md index b60d47e56b..68af97f62e 100644 --- a/changelog.d/uc-deductions-floor-reconciliation.fixed.md +++ b/changelog.d/uc-deductions-floor-reconciliation.fixed.md @@ -1,5 +1,5 @@ -- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. -- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor, leaving `benefit_cap_reduction` gross for Housing Benefit. +- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. Where the floor binds, deductions are met first and the cap reduction absorbs the rest, however heavily the award is capped. +- Added `uc_benefit_cap_reduction`, the benefit cap reduction applied to a Universal Credit award after the protected floor and bounded by the award, leaving `benefit_cap_reduction` gross for Housing Benefit. - Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. - Fixed Universal Credit deductions applying to benefit units that would not claim Universal Credit. -- Corrected the effective date of the 25% deductions cap to 30 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. +- Corrected the effective date of the 25% deductions cap to 12 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. diff --git a/docs/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index c84be05510..8314085a61 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -49,11 +49,13 @@ The latent distribution is observed in 2025 and applied to all model years as a ### The protected floor binds on last resort deductions -The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. The benefit cap reduction absorbs the floor first: `uc_deductions` is cut only where deductions alone still exceed the allowance. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions essentially never breach the floor on their own — only benefit cap reductions push past it. JRF's own worked example (standard allowance £92, deduction £14, benefit cap £59, floor £78) works out the same way: the £14 deduction survives in full and the £59 cap reduction goes. +The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. Where the current-law split already fits inside that allowance, nothing changes. Where it does not, deductions are met first from the protected award, up to the allowance, and the benefit cap reduction absorbs the rest. Both `uc_deductions` and `uc_benefit_cap_reduction` come from one allocation (`_reduction_allocation.py`), so they agree by construction. + +JRF specifies the combined limit, not a priority between the two: their [September 2026 briefing](https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty) proposes that "the sum of the 'capped amount' and any debt deductions is limited to the difference between a household's standard allowance and the level of the protected minimum floor". Debt first is the modeling choice, for three reasons. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions never breach the floor on their own, and the policy JRF builds on (extending the Fair Repayment Rate "to all deductions, including the benefit cap") leaves deductions where that rate puts them. JRF's own example (standard allowance £98 a week, a £15 deduction, an £80 cap reduction, total limited to £15) is consistent with it. And it does not depend on how heavily the household is capped: if the deduction were limited by the gross cap reduction, a household capped down to a few pence would have its deduction squeezed to almost nothing even though the floor then removes most of that cap. The award is the same under either priority; only the split differs. Where deductions alone do exceed the allowance, the floor binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. -Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone. `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. +Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction` sum to the entitlement forgone, and the applied cap reduction never exceeds the award (a £1,000 cap reduction on a £500 award applies £500). `benefit_cap_reduction` itself stays gross: it also drives Housing Benefit, which the Universal Credit floor does not protect. Read `uc_benefit_cap_reduction` for the cap reduction applied to a UC award under a floor reform. ### Other assumptions @@ -81,7 +83,7 @@ Both components report what the award actually loses, so `uc_deductions` and `uc {"gov.dwp.universal_credit.deductions.protected_floor": {"2026-01-01.2030-12-31": 0.85}} ``` -A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because the cap reduction absorbs the floor first, where nearly all of the modelled effect lands. +A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because deductions are met first, where nearly all of the modeled effect lands. ## Where the assignment lives diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml index 7cc392ffbb..bfe577cc36 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml @@ -1,8 +1,8 @@ -description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 30 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). +description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 12 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021; DWP Annual Report and Accounts 2020-21), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). values: 2015-04-01: 0.4 2019-10-01: 0.3 - 2021-04-30: 0.25 + 2021-04-12: 0.25 2025-04-30: 0.15 metadata: label: UC deductions cap @@ -23,5 +23,7 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5e68d0dce90e070ac74f08ea/Budget_2020_Web_Accessible_Complete.pdf#page=73 - title: HM Treasury, Budget 2021 (25% cap brought forward to April 2021) href: https://www.gov.uk/government/publications/budget-2021-documents/budget-2021-html + - title: DWP Annual Report and Accounts 2020-21, p.57 (cap reduced from 30% to 25% from 12 April 2021) + href: https://assets.publishing.service.gov.uk/media/60fa97498fa8f5043411d83d/dwp-annual-report-and-accounts-2020-2021-web-ready.pdf#page=58 - title: Universal Credit change brings £420 boost to over a million households (Fair Repayment Rate, 30 April 2025) href: https://www.gov.uk/government/news/universal-credit-change-brings-420-boost-to-over-a-million-households diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml index 0da2c48b45..50f754caa9 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml @@ -1,4 +1,4 @@ -description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The benefit cap reduction absorbs the floor first (uc_benefit_cap_reduction), so deductions are limited only where they alone exceed the allowance - matching JRF's worked example, which preserves the deduction in full and eliminates the benefit cap reduction. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. +description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. Where the current-law split already fits inside the allowance nothing changes; where it does not, deductions are met first, up to the allowance, and the benefit cap reduction absorbs the rest (uc_benefit_cap_reduction). JRF fixes the combined limit, not the priority between the two, so this ordering is a modeling choice; it reproduces JRF's September 2026 example, where a 15% deduction survives and the cap reduction goes. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. values: 2015-04-01: 0 metadata: @@ -6,5 +6,5 @@ metadata: unit: /1 period: year reference: - - title: JRF, A protected minimum floor in Universal Credit (October 2025 briefing; proposal design source, not current law) - href: https://www.jrf.org.uk/social-security/protected-minimum-floor-in-universal-credit-new-policy-targeting-hardship + - title: JRF, A protected minimum floor in Universal Credit to cut deep child poverty (11 September 2026, replacing the 9 October 2025 briefing; proposal design source, not current law) + href: https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index 373d79894f..a012fb2c8b 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -9,6 +9,12 @@ YEAR = 2025 +FLOOR_85 = { + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } +} + def make_situation(**benunit_overrides): benunit = { @@ -215,11 +221,10 @@ def test_floor_components_reconcile_with_the_reduction_applied(self): assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): - # JRF's worked example (standard allowance £92, deduction £14, - # benefit cap £59, floor £78) keeps the £14 deduction whole and - # eliminates the £59 cap reduction. Deductions of 10% of the standard - # allowance fit inside a 15% floor allowance, so they survive intact - # and the cap reduction takes the whole remaining squeeze. + # Deductions of 10% of the standard allowance fit inside a 15% floor + # allowance, so they survive intact and the cap reduction keeps only + # the 5% left. test_jrf_2026_example_keeps_a_full_rate_deduction + # covers JRF's own example, where a 15% deduction fills the allowance. situation = make_situation( benefit_cap_reduction={YEAR: 2_500}, uc_latent_deduction_rate={YEAR: 0.10}, @@ -239,6 +244,87 @@ def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): 0.05 * sa ) + def test_jrf_2026_example_keeps_a_full_rate_deduction(self): + # JRF's September 2026 briefing: standard allowance £98 a week, a + # Fair Repayment Rate deduction of £15 and a benefit cap reduction of + # £80; an 85% floor limits the total reduction to £15 and the + # household gains £80. Latent demand of 25% is cut to the 15% cap, + # which exactly fills the floor allowance, so the deduction survives + # whole and the cap reduction falls to zero. + situation = make_situation( + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + sa = Simulation(situation=situation).calculate("uc_standard_allowance", YEAR)[0] + situation["benunits"]["benunit"]["benefit_cap_reduction"] = {YEAR: 80 / 98 * sa} + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == pytest.approx( + 0, abs=0.01 + ) + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + + @pytest.mark.parametrize("gross_cap_reduction", [5_500, 5_900]) + def test_heavy_capping_does_not_squeeze_the_deduction(self, gross_cap_reduction): + # Under the gross cap the penny rule leaves room for only £499.88 or + # £99.88 of deductions. The floor removes all but the allowance, so + # the deduction is met in full from the protected award and the cap + # reduction absorbs the floor: £720.25 and £0, not £499.88/£220.37 + # or £99.88/£620.37. + situation = make_situation( + benefit_cap_reduction={YEAR: gross_cap_reduction}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == gross_cap_reduction + + @pytest.mark.parametrize("floor", [0, 0.85]) + def test_applied_cap_never_exceeds_the_award_lost(self, floor): + # A £1,000 gross cap reduction on a £500 award takes £500, not £1,000 + # (no floor) or the £720.25 floor allowance. The gross figure, which + # Housing Benefit reads, is unchanged. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 500}, + benefit_cap_reduction={YEAR: 1_000}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": floor + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 500 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + assert reform.calculate("benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_floor_changes_nothing_where_it_does_not_bind(self): + # A £700 award, a £1,000 gross cap and deduction demand above the + # floor allowance: the cap already takes the whole award, which is + # less than the £720.25 allowance, so the floor leaves the current-law + # split alone rather than moving the loss from the cap to deductions. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 700}, + benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 700 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + def test_a_floor_above_the_standard_allowance_adds_nothing(self): # (1 - floor) x standard allowance goes negative above a floor of 1; # subtracting it added £960 to a £6,000 award. The allowance clamps diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py new file mode 100644 index 0000000000..b22e6ff0f0 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py @@ -0,0 +1,61 @@ +"""Split a Universal Credit award's reductions between debt deductions and +the benefit cap. + +uc_deductions and uc_benefit_cap_reduction both call this, so they agree by +construction, and neither reads the other, which keeps the graph acyclic. +""" + +from policyengine_uk.model_api import * + + +def allocate_uc_reductions( + pre_cap_award, gross_cap_reduction, deduction_demand, max_reduction, minimum_payable +): + """Return (deductions, applied cap reduction) for each benefit unit. + + pre_cap_award: universal_credit_pre_benefit_cap. + gross_cap_reduction: benefit_cap_reduction, the regulation 81 amount of + SI 2013/376. Housing Benefit reads it too, so it is never floored. + deduction_demand: the deduction rate times the standard allowance, + before the one-penny rule. + max_reduction: uc_maximum_reduction, infinite when no protected floor is + in force (current law). + minimum_payable: the one-penny rule of Schedule 6 paragraph 3(1)(a) of + SI 2013/380, annualised. + + Current law: regulation 81 reduces the award by the cap first, and + deductions come out of what is left, leaving at least a penny payable + (main's operations, in main's order). The applied cap reduction is the + award actually lost, so it is bounded by the award; the gross reduction + can exceed it. + + Under a protected floor nothing changes unless that split takes more + than the floor allowance. Where it does, deductions are met first from + the protected award, up to the allowance, and the cap reduction takes + whatever allowance is left. Working from the deduction demand rather + than the current-law deduction matters for heavily capped awards: there + the penny rule had already squeezed the deduction under the gross cap, + but the floor removes most of that cap. + """ + floor_active = np.isfinite(max_reduction) + + # Current law. + award = max_(pre_cap_award - gross_cap_reduction, 0) + deductions = min_(deduction_demand, max_(award - minimum_payable, 0)) + cap_reduction = min_(gross_cap_reduction, max_(pre_cap_award - deductions, 0)) + + # Protected floor binding: deductions first, the cap reduction absorbs. + binds = floor_active & (cap_reduction + deductions > max_reduction) + floor_deductions = min_( + min_(deduction_demand, max_reduction), + max_(pre_cap_award - minimum_payable, 0), + ) + penny = where(floor_deductions > 0, minimum_payable, 0) + floor_cap_reduction = min_( + min_(gross_cap_reduction, max_(max_reduction - floor_deductions, 0)), + max_(pre_cap_award - floor_deductions - penny, 0), + ) + return ( + where(binds, floor_deductions, deductions), + where(binds, floor_cap_reduction, cap_reduction), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py index 9cf7698b3e..eb569c6cb4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) class uc_deductions(Variable): @@ -26,23 +29,19 @@ def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.deductions rate = benunit("uc_deduction_rate", period) standard_allowance = benunit("uc_standard_allowance", period) - award = max_( - benunit("universal_credit_pre_benefit_cap", period) - - benunit("benefit_cap_reduction", period), - 0, + # Shared with uc_benefit_cap_reduction. Under current law the cap + # reduces the award first and deductions leave a penny of what is + # left. Where a protected minimum floor binds, deductions are met + # first, up to the floor allowance, and the cap reduction absorbs the + # rest. The floor then also binds on the above-cap excess that + # current law exempts from the deductions cap (last resort and child + # maintenance deductions); JRF's briefing does not say whether their + # floor exempts those categories, so that is a modeling choice. + deductions, _ = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("benefit_cap_reduction", period), + rate * standard_allowance, + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, ) - minimum_payable = p.minimum_payable * MONTHS_IN_YEAR - deductions = min_(rate * standard_allowance, max_(award - minimum_payable, 0)) - # The protected minimum floor (zero, and so inactive, under current - # law) limits deductions and benefit cap reductions combined. The - # benefit cap reduction absorbs it first, so deductions bend only - # where they alone exceed the floor allowance: under the Fair - # Repayment Rate the 15% deductions cap equals a typical 15% floor - # allowance, so cappable deductions essentially never breach the - # floor on their own. The floor does bind on the above-cap excess - # that current law exempts from the deductions cap (last resort and - # child maintenance deductions). JRF's briefing does not say whether - # their floor exempts those categories - its worked example involves - # only cappable deductions and the benefit cap - so this is a - # modeling choice, tracked for follow-up. - return min_(deductions, benunit("uc_maximum_reduction", period)) + return deductions diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py index 081dc56c67..af45f75bc5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_benefit_cap_reduction.py @@ -1,17 +1,21 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) class uc_benefit_cap_reduction(Variable): - label = "UC benefit cap reduction" + label = "UC benefit cap reduction applied" documentation = ( "Benefit cap reduction actually taken off this benefit unit's " - "Universal Credit award. Equal to benefit_cap_reduction under " - "current law. Where a protected minimum floor is in force, the cap " - "reduction absorbs the floor first, so only the part of it fitting " - "in the allowance uc_deductions leaves is applied. " - "benefit_cap_reduction itself stays gross, because it also applies " - "to Housing Benefit, which the Universal Credit floor does not " - "protect." + "Universal Credit award. Under current law this is " + "benefit_cap_reduction, bounded by the award: a cap reduction larger " + "than the award takes the award to zero and no further. Where a " + "protected minimum floor binds, deductions are met first, up to the " + "floor allowance, and this takes whatever allowance is left. " + "benefit_cap_reduction itself stays gross, because Housing Benefit " + "also reads it and the Universal Credit floor does not protect " + "Housing Benefit." ) entity = BenUnit definition_period = YEAR @@ -20,17 +24,17 @@ class uc_benefit_cap_reduction(Variable): defined_for = "would_claim_uc" def formula(benunit, period, parameters): - reduction = benunit("benefit_cap_reduction", period) - # Deductions take their share of the floor allowance first, so the - # cap reduction is what bends when the floor binds. Under the Fair - # Repayment Rate the 15% deductions cap equals a typical 15% floor - # allowance, so cappable deductions alone essentially never breach - # the floor - only benefit cap reductions push past it. JRF's worked - # example (standard allowance £92, deduction £14, benefit cap £59, - # floor £78) preserves the £14 deduction and eliminates the £59 cap - # reduction, which is this ordering. - headroom = max_( - benunit("uc_maximum_reduction", period) - benunit("uc_deductions", period), - 0, + p = parameters(period).gov.dwp.universal_credit.deductions + # JRF's September 2026 example (standard allowance £98 a week, a £15 + # Fair Repayment Rate deduction, an £80 cap reduction, an 85% floor) + # limits the total to £15. Meeting the deduction first, as here, + # removes the whole cap reduction. + _, cap_reduction = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("benefit_cap_reduction", period), + benunit("uc_deduction_rate", period) + * benunit("uc_standard_allowance", period), + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, ) - return min_(reduction, headroom) + return cap_reduction diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index 5230ba6324..31bf7b86f6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -11,12 +11,11 @@ class universal_credit(Variable): def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) - # Both components are already limited by the protected minimum floor - # (zero, and so inactive, under current law): uc_deductions takes its - # share of the floor allowance first and uc_benefit_cap_reduction - # takes what is left. Subtracting them separately, rather than - # limiting their sum here, keeps the reported components summing to - # the reduction actually applied. + # Both components already reflect the protected minimum floor (zero, + # and so inactive, under current law), so the reported components sum + # to the reduction actually applied. Summing them before subtracting + # keeps main's float arithmetic, so current-law awards are unchanged + # to the bit. benefit_cap_reduction = benunit("uc_benefit_cap_reduction", period) deductions = benunit("uc_deductions", period) - return max_(uc_max_entitlement - benefit_cap_reduction - deductions, 0) + return max_(uc_max_entitlement - (benefit_cap_reduction + deductions), 0) From e9b20e5d40a1e29ba91570775b118e4be5b682eb Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 12 Oct 2026 14:50:51 +0200 Subject: [PATCH 056/224] 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 7784193a9d7d309e3370553bebf6b76768b6d329 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 10:20:37 -0400 Subject: [PATCH 057/224] Count share-like capital as one block that keeps the supplied aggregate The independent review of 21412b6a found that reconstructing corporate_wealth as components plus a floored residual could tip a household across the capital limit on float32 rounding (components summing to 16,000.0009 against a supplied 16,000), and changed unsplit inputs with a negative corporate_wealth. The six capital lists now name one formula variable, share_like_capital: corporate_wealth as supplied, unless the itemised components add up to more than it beyond a rounding tolerance (1e-5 relative plus a penny), in which case their sum. Every dataset that carries only corporate_wealth, or builds it as the components' sum, counts exactly corporate_wealth; a household given only components has them counted. Tests: a differential against the original corporate_wealth lists on identical inputs (unsplit with negative and fractional aggregates, and consistent splits), annual and monthly, asserting capital and UC eligibility equal exactly; the float32 limit case and the negative aggregate; and the block's definition as a property. Replacing the formula with the old floored residual fails the float32 test. Also: the pension-age HB home disregard is Schedule 6 paragraph 26 of SI 2006/214, not paragraph 1 (review finding, predates this PR). Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 38 ++-- .../gov/dwp/JSA/income/capital/sources.yaml | 40 ++-- .../means_test/capital/sources.yaml | 47 +++-- .../means_test/capital/sources.yaml | 10 +- .../income/capital/sources.yaml | 11 +- .../means_test/capital/sources.yaml | 55 ++--- .../tests/test_capital_share_components.py | 199 ++++++++++++------ .../household/wealth/share_like_capital.py | 43 ++++ .../wealth/unitemised_corporate_wealth.py | 34 --- 10 files changed, 283 insertions(+), 196 deletions(-) create mode 100644 policyengine_uk/variables/household/wealth/share_like_capital.py delete mode 100644 policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index aa4202f98b..a161d13f25 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count them and the stocks and shares ISA in place of `corporate_wealth` in every means-test capital sources list (with `unitemised_corporate_wealth` carrying any part a dataset does not itemise), and uprate them and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count share-like capital in every means test as `corporate_wealth` or, where larger, the sum of its components (`share_like_capital`), and uprate the components and the ISA balances with per-capita GDP. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index e0bd6f429d..a5de71cb3e 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,20 +3,25 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, the share-like sources, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 9 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 9 disregards and the higher residential-care capital disregard are - not yet modeled because the current dataset does not isolate those states or - asset holdings. Because the dataset only provides household-level capital - stocks, the model allocates the full household total to benunits with a - reported income-related ESA award and only falls back to an adult-share - proxy when nobody in the household is on that reported claim path. That - allocation is a data approximation, not a legislated ownership rule. + held directly, the share-like sources, `other_residential_property_value` + for residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards + premises occupied as the claimant's home. `private_pension_wealth` is + excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the + right to receive an occupational or personal pension and the value of funds + held under a personal pension scheme. Other Schedule 9 disregards and the + higher residential-care capital disregard are not yet modeled because the + current dataset does not isolate those states or asset holdings. Because the + dataset only provides household-level capital stocks, the model allocates + the full household total to benunits with a reported income-related ESA + award and only falls back to an adult-share proxy when nobody in the + household is on that reported claim path. That allocation is a data + approximation, not a legislated ownership rule. metadata: label: Income-related ESA countable capital sources period: year @@ -32,9 +37,6 @@ values: 2008-10-27: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index e6e2f55294..7c5262e767 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,21 +3,26 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, the share-like sources, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 8 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 8 disregards, the higher residential-care capital disregard, and - the separate child-capital disregard are not yet modeled because the current - dataset does not isolate those states or asset holdings. Because the dataset - only provides household-level capital stocks, the model allocates the full - household total to benunits with a reported income-based JSA award and only - falls back to an adult-share proxy when nobody in the household is on that - reported claim path. That allocation is a data approximation, not a - legislated ownership rule. + directly, the share-like sources, `other_residential_property_value` for + residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards + premises occupied as the claimant's home. `private_pension_wealth` is + excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the + right to receive an occupational or personal pension and the value of funds + held under a personal pension scheme. Other Schedule 8 disregards, the + higher residential-care capital disregard, and the separate child-capital + disregard are not yet modeled because the current dataset does not isolate + those states or asset holdings. Because the dataset only provides + household-level capital stocks, the model allocates the full household total + to benunits with a reported income-based JSA award and only falls back to an + adult-share proxy when nobody in the household is on that reported claim + path. That allocation is a data approximation, not a legislated ownership + rule. metadata: label: Income-based JSA countable capital sources period: year @@ -33,9 +38,6 @@ values: 1996-10-07: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index d4522d2197..dbf6a86c0b 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,23 +1,27 @@ description: > - Model variables currently mapped to countable Housing Benefit capital. - The Housing Benefit Regulations 2006 treat the whole of a claimant's capital - as assessable unless it is disregarded under Schedule 6. The current mappings - are `savings` for liquid capital, `owned_land` for land held directly, - the share-like sources, - `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. - `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards - premises occupied as the claimant's home. `private_pension_wealth` is - excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the - right to receive an occupational or personal pension and the value of funds - held under a personal pension scheme (paragraph 24 of Schedule 6 to the - pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the + Model variables currently mapped to countable Housing Benefit capital. The + Housing Benefit Regulations 2006 treat the whole of a claimant's capital as + assessable unless it is disregarded under Schedule 6. The current mappings + are `savings` for liquid capital, `owned_land` for land held directly, the + share-like sources, `other_residential_property_value` for residential + property other than the home, and `non_residential_property_value` for other + land and premises. `share_like_capital` is `corporate_wealth` as supplied, + or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. `main_residence_value` is excluded because Schedule 6 + paragraph 1 disregards premises occupied as the claimant's home (paragraph + 26 of Schedule 6 to the pension-age regulations). `private_pension_wealth` + is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of + the right to receive an occupational or personal pension and the value of + funds held under a personal pension scheme (paragraph 24 of Schedule 6 to + the pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it - inside `unitemised_corporate_wealth`. Other Schedule 6 disregards within these broad - asset classes are not yet split out separately. Because the - dataset only provides household-level capital stocks, the model allocates - them across benunits using an adult-share proxy. + inside `share_like_capital`. Other Schedule 6 disregards within these broad + asset classes are not yet split out separately. Because the dataset only + provides household-level capital stocks, the model allocates them across + benunits using an adult-share proxy. metadata: label: Housing Benefit countable capital sources period: year @@ -33,17 +37,14 @@ metadata: href: https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/32 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 44(1) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/44 - - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 1 - href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/1 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 26 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/26 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 24 href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/24 values: 2006-04-10: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 336efd6e3f..971341aa06 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,7 +3,10 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. - `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. metadata: label: Income Support capital sources period: year @@ -23,9 +26,6 @@ values: 2010-01-01: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index d5fb5b4388..b567570949 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,11 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. + (regulation 18) is not modelled. `share_like_capital` is `corporate_wealth` + as supplied, or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,7 +29,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 63919f2699..c4615ccf9c 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,31 +1,35 @@ description: > Model variables currently mapped to countable Universal Credit capital. - Regulation 46 takes the whole of a person's capital into account unless it is - treated as income or disregarded under regulation 48 and Schedule 10. The + Regulation 46 takes the whole of a person's capital into account unless it + is treated as income or disregarded under regulation 48 and Schedule 10. The current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the home, `non_residential_property_value` for other land and premises, and the - share-like sources. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` - is excluded because Schedule 10 paragraph 1 disregards premises occupied as - the person's home. `private_pension_wealth` is excluded because Schedule 10 - paragraph 10 disregards the value of any right to receive a pension under an - occupational or personal pension scheme (or any other scheme registered under - section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the - Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead; datasets built before it - was split out of `corporate_wealth` still carry it inside - `unitemised_corporate_wealth`. Other Schedule 10 disregards within these broad asset - classes are not yet split out separately. Where only household-level capital - is available for a multi-benunit household, the model uses a household-wide - adult-share proxy for residual capital because the dataset does not identify - which adults hold the beneficial interest in each capital asset. The - denominator is all unreported adults in the household, including adults in - benunits that are not themselves UC claimants. That proxy is a data - approximation, not a legislated allocation rule. Regulation 47 is cited only - as related context on jointly held capital. If some benunits provide - `uc_reported_capital` (by default the benefit unit's recorded capital, - `benunit_reported_capital`), the proxy is applied only to the residual - household capital for benunits that remain unreported. + share-like sources. `share_like_capital` is `corporate_wealth` as supplied, + or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. `main_residence_value` is excluded because Schedule 10 + paragraph 1 disregards premises occupied as the person's home. + `private_pension_wealth` is excluded because Schedule 10 paragraph 10 + disregards the value of any right to receive a pension under an occupational + or personal pension scheme (or any other scheme registered under section 153 + of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland + regulations makes the same disregard); pension income actually drawn counts + as unearned income instead; datasets built before it was split out of + `corporate_wealth` still carry it inside `share_like_capital`. Other + Schedule 10 disregards within these broad asset classes are not yet split + out separately. Where only household-level capital is available for a + multi-benunit household, the model uses a household-wide adult-share proxy + for residual capital because the dataset does not identify which adults hold + the beneficial interest in each capital asset. The denominator is all + unreported adults in the household, including adults in benunits that are + not themselves UC claimants. That proxy is a data approximation, not a + legislated allocation rule. Regulation 47 is cited only as related context + on jointly held capital. If some benunits provide `uc_reported_capital` (by + default the benefit unit's recorded capital, `benunit_reported_capital`), + the proxy is applied only to the residual household capital for benunits + that remain unreported. metadata: label: Universal Credit countable capital sources period: year @@ -48,7 +52,4 @@ values: - savings - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 8d4a5201ce..3194e95198 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -1,24 +1,32 @@ -"""Itemising corporate_wealth never changes a household's capital total. +"""share_like_capital replaces corporate_wealth in the means-test capital lists. -The means-test capital sources list corporate_wealth's components and -unitemised_corporate_wealth (the part a dataset does not itemise) instead of -corporate_wealth. With no valuation rule that tells the components apart, every -programme's assessable capital must equal what it is when the same holdings are -entered as one unitemised corporate_wealth. +Invariants: +1. share_like_capital is corporate_wealth as supplied (any sign, any fraction) + unless the itemised components add up to more than it by more than the + rounding tolerance, in which case it is their sum. +2. Differential: for identical inputs that carry no components, or components + consistent with corporate_wealth, every programme's assessable capital and + eligibility equal those under the original lists (corporate_wealth), + exactly, annually and monthly. +3. A household given only the components has them counted. """ +import numpy as np import pytest -from hypothesis import given, settings +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.variables.household.wealth.share_like_capital import ( + ABSOLUTE_TOLERANCE, + RELATIVE_TOLERANCE, +) COMPONENTS = ( "directly_held_shares", "unit_and_investment_trusts", "stocks_and_shares_isa", ) -SHARE_LIKE_SOURCES = (*COMPONENTS, "unitemised_corporate_wealth") SOURCE_LISTS = { "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", @@ -27,11 +35,14 @@ "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", } -AGE = {"pension_credit_assessable_capital": 70} YEAR = 2026 +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) system = CountryTaxBenefitSystem() -amount = st.integers(min_value=0, max_value=60_000) def _parameter(path): @@ -41,70 +52,130 @@ def _parameter(path): return node(f"{YEAR}-01-01") -def _capital(variable, household): - situation = { - "people": {"adult": {"age": {YEAR: AGE.get(variable, 30)}}}, - "benunits": {"benunit": {"members": ["adult"], "would_claim_uc": {YEAR: True}}}, - "households": { - "household": { - "members": ["adult"], - **{name: {YEAR: value} for name, value in household.items()}, - } - }, - } - return Simulation(situation=situation).calculate(variable, YEAR)[0] +ORIGINAL_LISTS = { + path: [ + "corporate_wealth" if source == "share_like_capital" else source + for source in _parameter(path) + ] + for path in SOURCE_LISTS.values() +} +ORIGINAL_REFORM = { + path: {str(YEAR): sources} for path, sources in ORIGINAL_LISTS.items() +} + +money = st.floats(min_value=0, max_value=200_000, allow_nan=False, width=32) +signed_money = st.floats(min_value=-5_000, max_value=200_000, allow_nan=False, width=32) + + +def _situation(households): + """One single-adult household per entry; ages alternate 30 and 70.""" + people, benunits, hhs = {}, {}, {} + for i, assets in enumerate(households): + people[f"p{i}"] = {"age": {YEAR: 70 if i % 2 else 30}} + benunits[f"b{i}"] = {"members": [f"p{i}"], "would_claim_uc": {YEAR: True}} + hhs[f"h{i}"] = { + "members": [f"p{i}"], + **{name: {YEAR: float(value)} for name, value in assets.items()}, + } + return {"people": people, "benunits": benunits, "households": hhs} + + +def _results(households, reform=None, period=YEAR): + sim = Simulation(situation=_situation(households), reform=reform) + out = {v: sim.calculate(v, period) for v in SOURCE_LISTS} + out["is_uc_eligible"] = sim.calculate("is_uc_eligible", period) + out["share_like_capital"] = sim.calculate("share_like_capital", YEAR) + return out + + +def _assert_same_as_original(households): + for period in (YEAR, f"{YEAR}-01"): + new = _results(households, period=period) + old = _results(households, reform=ORIGINAL_REFORM, period=period) + for variable in (*SOURCE_LISTS, "is_uc_eligible"): + assert np.array_equal(new[variable], old[variable]), (variable, period) @pytest.mark.parametrize("variable", SOURCE_LISTS) -def test_lists_name_the_components_not_corporate_wealth(variable): +def test_lists_name_share_like_capital_only(variable): sources = list(_parameter(SOURCE_LISTS[variable])) + assert "share_like_capital" in sources assert "corporate_wealth" not in sources - assert set(SHARE_LIKE_SOURCES) <= set(sources) + assert not set(COMPONENTS) & set(sources) -@settings(max_examples=40, deadline=None) +@SETTINGS @given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), + st.lists( + st.tuples(signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=40, + ) ) -def test_unitemised_corporate_wealth_is_the_floored_remainder( - savings, corporate_wealth, components -): - household = {"savings": savings, "corporate_wealth": corporate_wealth} - household.update(dict(zip(COMPONENTS, components))) - situation = { - "people": {"adult": {"age": {YEAR: 30}}}, - "households": { - "household": { - "members": ["adult"], - **{name: {YEAR: value} for name, value in household.items()}, - } - }, - } - residual = Simulation(situation=situation).calculate( - "unitemised_corporate_wealth", YEAR - )[0] - assert residual == max(0, corporate_wealth - sum(components)) +def test_share_like_capital_keeps_the_supplied_aggregate(rows): + households = [] + for corporate_wealth, components, itemise in rows: + assets = {"corporate_wealth": corporate_wealth} + if itemise: + assets |= dict(zip(COMPONENTS, components)) + households.append(assets) + got = _results(households)["share_like_capital"] + for value, assets in zip(got, households): + cw = np.float32(assets["corporate_wealth"]) + itemised = np.float32(sum(np.float32(assets.get(c, 0)) for c in COMPONENTS)) + tolerance = RELATIVE_TOLERANCE * abs(cw) + ABSOLUTE_TOLERANCE + expected = itemised if itemised > 0 and itemised > cw + tolerance else cw + assert value == pytest.approx(expected, rel=1e-6, abs=1e-3) + if not (itemised > 0 and itemised > cw + tolerance): + assert value == cw # exactly the supplied aggregate -@pytest.mark.parametrize("variable", SOURCE_LISTS) -@settings(max_examples=15, deadline=None) +@SETTINGS @given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), -) -def test_itemising_never_changes_capital( - variable, savings, corporate_wealth, components -): - """Split holdings count exactly as max(corporate_wealth, their sum) does.""" - itemised = {"savings": savings, "corporate_wealth": corporate_wealth} - itemised.update(dict(zip(COMPONENTS, components))) - unitemised = { - "savings": savings, - "corporate_wealth": max(corporate_wealth, sum(components)), - } - assert _capital(variable, itemised) == pytest.approx( - _capital(variable, unitemised), abs=1e-2 + st.lists( + st.tuples(money, signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=30, ) +) +def test_capital_matches_the_original_lists(rows): + """Unsplit, or split consistently: identical to listing corporate_wealth.""" + households = [] + for savings, corporate_wealth, components, split in rows: + assets = {"savings": savings, "corporate_wealth": corporate_wealth} + if split: + assets |= dict(zip(COMPONENTS, components)) + assets["corporate_wealth"] = float(sum(components)) + households.append(assets) + _assert_same_as_original(households) + + +def test_float32_rounding_at_the_limit_keeps_eligibility(): + """Components whose float32 sum overshoots £16,000 by a fraction of a penny.""" + households = [ + { + "savings": 0, + "corporate_wealth": 16_000, + "directly_held_shares": 6_744.81, + "unit_and_investment_trusts": 6_581.24, + "stocks_and_shares_isa": 2_673.95, + }, + {"savings": 6_001, "corporate_wealth": -2}, + {"savings": 15_999.99, "corporate_wealth": 0.01}, + ] + _assert_same_as_original(households) + assert _results(households)["share_like_capital"][0] == 16_000 + + +def test_components_alone_are_counted(): + households = [ + { + "savings": 1_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 5_000, + } + ] + got = _results(households) + assert got["share_like_capital"][0] == 12_000 + assert got["uc_assessable_capital"][0] == 13_000 diff --git a/policyengine_uk/variables/household/wealth/share_like_capital.py b/policyengine_uk/variables/household/wealth/share_like_capital.py new file mode 100644 index 0000000000..967be954db --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + +# Itemised holdings within this much of corporate_wealth are the same holdings +# rounded differently (float32 sums of the components), not extra wealth. +RELATIVE_TOLERANCE = 1e-5 +ABSOLUTE_TOLERANCE = 0.01 + + +class share_like_capital(Variable): + label = "share-like capital" + documentation = ( + "Share-like holdings counted by the means-test capital tests: " + "corporate_wealth as supplied, unless its itemised components " + "(directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa) add up to more than it, in which case their sum. " + "So datasets that carry only corporate_wealth, or build it as the " + "components' sum, count exactly corporate_wealth (any sign), and a " + "household given only the components has them counted. A gap within " + "a hundred-thousandth of corporate_wealth plus a penny is treated as " + "rounding, so the supplied aggregate is kept. Private pension wealth is " + "not part of it; datasets built before private pension wealth was " + "split out of corporate_wealth still carry it here." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + corporate_wealth = household("corporate_wealth", period) + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + tolerance = RELATIVE_TOLERANCE * abs(corporate_wealth) + ABSOLUTE_TOLERANCE + exceeds = (itemised > 0) & (itemised > corporate_wealth + tolerance) + return where(exceeds, itemised, corporate_wealth) diff --git a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py deleted file mode 100644 index 1d7fcd56a1..0000000000 --- a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py +++ /dev/null @@ -1,34 +0,0 @@ -from policyengine_uk.model_api import * - - -class unitemised_corporate_wealth(Variable): - label = "corporate wealth not itemised into components" - documentation = ( - "The part of corporate_wealth not carried in its itemised components " - "directly_held_shares, unit_and_investment_trusts and " - "stocks_and_shares_isa, floored at nil. On datasets without the " - "components it equals corporate_wealth (including the private pension " - "wealth that datasets built before that split fold into it); on datasets " - "that build corporate_wealth as the exact sum of the components it is " - "about nil. The means-test capital sources list the components and this " - "residual instead of corporate_wealth, so each itemised asset can carry " - "its own valuation rule while datasets without the split keep counting " - "corporate_wealth as before, and no holding is counted twice." - ) - entity = Household - definition_period = YEAR - value_type = float - unit = GBP - quantity_type = STOCK - - def formula(household, period, parameters): - itemised = add( - household, - period, - [ - "directly_held_shares", - "unit_and_investment_trusts", - "stocks_and_shares_isa", - ], - ) - return max_(0, household("corporate_wealth", period) - itemised) From b7e6bc5c54feeb35fe8bcf979943f491be64e627 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 11:32:57 -0400 Subject: [PATCH 058/224] Split share-like capital by expenses of sale on #1974's block design #1974 replaced the components-plus-residual design with one share_like_capital block that keeps the supplied corporate_wealth exactly. This follow-up now splits that block in two: - share_like_capital_with_sale_expenses: share_like_capital less the itemised holdings in gov.dwp.share_like_capital.without_sale_expenses (unit and investment trusts, stocks and shares ISAs), floored at nil; all of it when nothing is itemised. - share_like_capital_without_sale_expenses: the rest, exactly nil when nothing is itemised. The six capital lists name both parts and the six sale-expense lists the first, so datasets that carry only corporate_wealth keep #1969's flat 10% exactly (differential test, annual and monthly, against the lists before itemising, including negative aggregates and a value whose 90% is a fraction of a penny over the limit). The exempt holdings are a parameter list, so the methodology call on investment trusts and ISAs (d1396) is a one-line change. Merged the refreshed #1969 (54ed063b1) and #1974 (7784193a). #1969's property oracle reads the share-like sources from an unitemised household's corporate_wealth; the split invariants (definition of both parts, the unitemised differential, split effect = 10% of trusts plus ISAs on UC capital, components-only valuation) are in test_capital_share_components. Dropping the nothing-itemised branch from the formula fails the differential. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sale-expense-share-split.fixed.md | 2 +- docs/book/assumptions/growthfactors.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 2 + .../income/capital/sale_expenses/sources.yaml | 34 +-- .../gov/dwp/ESA/income/capital/sources.yaml | 5 +- .../income/capital/sale_expenses/sources.yaml | 33 +-- .../gov/dwp/JSA/income/capital/sources.yaml | 5 +- .../capital/sale_expenses/sources.yaml | 36 +--- .../means_test/capital/sources.yaml | 5 +- .../capital/sale_expenses/sources.yaml | 33 +-- .../means_test/capital/sources.yaml | 5 +- .../income/capital/sale_expenses/sources.yaml | 33 +-- .../income/capital/sources.yaml | 3 +- .../without_sale_expenses.yaml | 29 +++ .../capital/sale_expenses/sources.yaml | 33 +-- .../means_test/capital/sources.yaml | 5 +- .../dwp/capital_valuation_share_split.yaml | 19 +- .../tests/test_capital_share_components.py | 197 ++++++++++-------- .../test_capital_valuation_properties.py | 29 ++- .../tests/test_stock_capital_periods.py | 13 +- .../share_like_capital_with_sale_expenses.py | 35 ++++ ...hare_like_capital_without_sale_expenses.py | 20 ++ 22 files changed, 320 insertions(+), 258 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml create mode 100644 policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py create mode 100644 policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py diff --git a/changelog.d/sale-expense-share-split.fixed.md b/changelog.d/sale-expense-share-split.fixed.md index 1060d7977b..014f38dd95 100644 --- a/changelog.d/sale-expense-share-split.fixed.md +++ b/changelog.d/sale-expense-share-split.fixed.md @@ -1 +1 @@ -- Apply the 10% sale-expense deduction to directly held shares only, so unit and investment trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value in every means test; datasets that do not itemise `corporate_wealth` keep its previous treatment. +- Value share-like capital by asset type in every means test: directly held shares, and any `corporate_wealth` a dataset does not itemise, take the 10% sale-expense deduction; unit trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value, as does the survey's combined unit and investment trust bucket, an approximation for its investment trusts. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index f58b658b7d..f75004a01f 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -62,7 +62,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_secured_debt`, `other_residential_property_value`, `owned_land`, `owned_land_secured_debt`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_secured_debt`, `other_residential_property_value`, `owned_land`, `owned_land_secured_debt`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index c3eb624a68..699e3958f4 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -95,6 +95,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - benunit_reported_capital - cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - household_lifetime_isa_balance @@ -122,6 +123,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income # A formula of benunit_reported_capital; listed so a dataset that stores it # directly is uprated like the input. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index 0f05cea7e0..384cd7d7db 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -6,28 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 52620-52623 and 52685). `directly_held_shares` is listed because - shares are valued less 10% for the costs of selling them (DMG 52671). - `unit_and_investment_trusts` is deliberately not listed: a unit trust is - valued at its bid price and has no costs of sale, because the person - withdraws from the manager, even if they use a stockbroker (DMG - 52680-52681). Investment trusts are not named in the guidance and, as listed - company shares, arguably take the 10%, but the Wealth and Assets Survey asks - about unit and investment trusts in one question, so the whole bucket is - treated as having no costs of sale. That approximation counts investment - trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts - at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed - either: an ISA is valued at what the person would get by withdrawing it, and - no ISA paragraph applies the 10% (DMG 52662-52664). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as income-related ESA capital are - ignored. + sale (DMG 52620-52623 and 52685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 52671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as + income-related ESA capital are ignored. metadata: label: Income-related ESA capital sources with sale expenses period: year @@ -52,5 +39,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index a5de71cb3e..6f75bf1b1e 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -6,7 +6,7 @@ description: > held directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other land and premises. - `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, so a household given only the components has them counted and no holding counts twice. @@ -37,6 +37,7 @@ values: 2008-10-27: - savings - owned_land - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index db4d9e0322..78f4811cb3 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -6,27 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). - `unit_and_investment_trusts` is deliberately not listed: a unit trust is - valued at its bid price and has no costs of sale, because the person - withdraws from the manager, even if they use a stockbroker (DMG - 29680-29681). Investment trusts are not named in the guidance and, as listed - company shares, arguably take the 10%, but the Wealth and Assets Survey asks - about unit and investment trusts in one question, so the whole bucket is - treated as having no costs of sale. That approximation counts investment - trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts - at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed - either: an ISA is valued at what the person would get by withdrawing it, and - no ISA paragraph applies the 10% (DMG 29662-29664). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as income-based JSA capital are ignored. + sale (DMG 29620-29623 and 29685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 29671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as income-based + JSA capital are ignored. metadata: label: Income-based JSA capital sources with sale expenses period: year @@ -49,5 +37,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 7c5262e767..0c0504f4b6 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -6,7 +6,7 @@ description: > directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other land and premises. - `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, so a household given only the components has them counted and no holding counts twice. @@ -38,6 +38,7 @@ values: 1996-10-07: - savings - owned_land - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index 84284223d9..3c48fff910 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -7,29 +7,16 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (HBGM BW1 Annex F and BP1 F2.03). `directly_held_shares` is listed - because shares are valued less 10% for selling costs (HBGM W1.513 and - P1.513). `unit_and_investment_trusts` is deliberately not listed: unit trust - holdings are valued at the bid price, which already takes account of the - selling expenses, and the 10% is not deducted even if they could be sold - through a bank or stockbroker (HBGM W1.530-W1.531 and P1.530-P1.531). - Investment trusts are not named in the guidance and, as listed company - shares, arguably take the 10%, but the Wealth and Assets Survey asks about - unit and investment trusts in one question, so the whole bucket is treated - as having no costs of sale. That approximation counts investment trusts at - full value; HMRC ISA statistics (table 9.6) put investment trusts at about 7% - to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed either: the HB - guidance manual has no ISA paragraph, and the DWP guidance on the parallel - Income Support and JSA rule values an ISA at what the person would get by - withdrawing it, with no ISA paragraph applying the 10% (DMG 29662-29664). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as Housing Benefit capital are ignored. + sale (HBGM BW1 Annex F and BP1 F2.03). + `share_like_capital_with_sale_expenses` is listed: the part of share-like + capital whose sale incurs expenses, namely directly held shares, valued less + 10% for selling costs (HBGM W1.513 and P1.513), and all of + `corporate_wealth` on datasets that do not itemise it. Unit and investment + trusts and stocks and shares ISAs are valued without the deduction; + `gov.dwp.share_like_capital.without_sale_expenses` lists them, with the + guidance and the approximation for investment trusts, which the survey + cannot separate from unit trusts. Sources not counted as Housing Benefit + capital are ignored. metadata: label: Housing Benefit capital sources with sale expenses period: year @@ -66,5 +53,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index dbf6a86c0b..b7178bf360 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -5,7 +5,7 @@ description: > are `savings` for liquid capital, `owned_land` for land held directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `share_like_capital` is `corporate_wealth` as supplied, + land and premises. `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, so a household given only the components has them counted and no holding @@ -45,6 +45,7 @@ values: 2006-04-10: - savings - owned_land - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index 06188a12fd..ef18e218f9 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -6,27 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `directly_held_shares` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). - `unit_and_investment_trusts` is deliberately not listed: a unit trust is - valued at its bid price and has no costs of sale, because the person - withdraws from the manager, even if they use a stockbroker (DMG - 29680-29681). Investment trusts are not named in the guidance and, as listed - company shares, arguably take the 10%, but the Wealth and Assets Survey asks - about unit and investment trusts in one question, so the whole bucket is - treated as having no costs of sale. That approximation counts investment - trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts - at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed - either: an ISA is valued at what the person would get by withdrawing it, and - no ISA paragraph applies the 10% (DMG 29662-29664). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as Income Support capital are ignored. + sale (DMG 29620-29623 and 29685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 29671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as Income + Support capital are ignored. metadata: label: Income Support capital sources with sale expenses period: year @@ -49,5 +37,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 971341aa06..e5e83161db 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,7 +3,7 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. - `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, so a household given only the components has them counted and no holding counts twice. @@ -26,6 +26,7 @@ values: 2010-01-01: - savings - owned_land - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index dae92445b4..dd312c18b8 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -6,27 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 84712-84715 and 84731). `directly_held_shares` is listed because - shares are valued less 10% for the costs of selling them (DMG 84763). - `unit_and_investment_trusts` is deliberately not listed: a unit trust is - valued at its bid price and has no costs of sale, because the person - withdraws from the manager, even if they use a stockbroker (DMG - 84771-84772). Investment trusts are not named in the guidance and, as listed - company shares, arguably take the 10%, but the Wealth and Assets Survey asks - about unit and investment trusts in one question, so the whole bucket is - treated as having no costs of sale. That approximation counts investment - trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts - at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed - either: an ISA is valued at what the person would get by withdrawing it, and - no ISA paragraph applies the 10% (DMG 84748-84750). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as Pension Credit capital are ignored. + sale (DMG 84712-84715 and 84731). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 84763), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as Pension + Credit capital are ignored. metadata: label: Pension Credit capital sources with sale expenses period: year @@ -51,5 +39,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index b567570949..9e80b09f52 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -29,4 +29,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml new file mode 100644 index 0000000000..086d7d849c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml @@ -0,0 +1,29 @@ +description: > + Itemised share-like holdings valued for every means test without the + deduction for expenses of sale. Unit trusts are valued at the bid price, + which the manager pays on withdrawal, with no costs of sale even when sold + through a broker (ADM H1673-H1674; DMG 29680-29681, 52680-52681, + 84771-84772; HBGM W1.530-W1.531). The Wealth and Assets Survey asks about + unit and investment trusts in one question, so the whole bucket is listed; + investment trusts are quoted shares, which take the deduction (ADM H1665), + and were 8.2% of the pooled funds held in stocks and shares ISAs at 5 April + 2022 and 6.9% at 5 April 2025 (HMRC ISA Table 9.6). An ISA is valued at + what the holder would get by withdrawing it, and no ISA paragraph applies + the deduction (ADM H1656-H1658; DMG 29662-29664, 52662, 84748-84750). + Everything else in share_like_capital takes the deduction: directly held + shares, and on datasets that do not itemise it, all of corporate_wealth. +metadata: + label: Share-like holdings without expenses of sale + period: year + unit: list + reference: + - title: Advice for Decision Making chapter H1, paras. H1656-H1658 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, paras. H1673-H1674 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 + - title: HMRC Annual savings statistics 2026, ISA Table 9.6 + href: https://www.gov.uk/government/statistics/annual-savings-statistics-2026 +values: + 2013-04-29: + - unit_and_investment_trusts + - stocks_and_shares_isa diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index f7a760beb9..0194852eb6 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -6,27 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (ADM H1604-H1607 and H1675). `directly_held_shares` is listed because - shares are valued less 10% for the costs of selling them (ADM H1665). - `unit_and_investment_trusts` is deliberately not listed: a unit trust is - valued at its bid price and has no costs of sale, because the person - withdraws from the manager, even if they use a stockbroker (ADM - H1673-H1674). Investment trusts are not named in the guidance and, as listed - company shares, arguably take the 10%, but the Wealth and Assets Survey asks - about unit and investment trusts in one question, so the whole bucket is - treated as having no costs of sale. That approximation counts investment - trusts at full value; HMRC ISA statistics (table 9.6) put investment trusts - at about 7% to 8% of the pooled funds held in stocks and shares ISAs (8.2% at 5 April - 2022, 6.9% at 5 April 2025). `stocks_and_shares_isa` is deliberately not listed - either: an ISA is valued at what the person would get by withdrawing it, and - no ISA paragraph applies the 10% (ADM H1656-H1658). - `unitemised_corporate_wealth` is listed so that datasets which do not - itemise `corporate_wealth` keep its previous flat treatment: there it equals - `corporate_wealth`, which mixes shares with unit trusts, stocks and shares - ISAs and employee share options, and, on datasets built before private - pension wealth was split out, non-defined-benefit pension wealth, which is - not capital at all. The deduction reaches all of these until the data - separate them. Sources not counted as Universal Credit capital are ignored. + sale (ADM H1604-H1607 and H1675). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (ADM H1665), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as Universal + Credit capital are ignored. metadata: label: Universal Credit capital sources with sale expenses period: year @@ -49,5 +37,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unitemised_corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index c4615ccf9c..d982144dba 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -5,7 +5,7 @@ description: > current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the home, `non_residential_property_value` for other land and premises, and the - share-like sources. `share_like_capital` is `corporate_wealth` as supplied, + share-like sources. `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, so a household given only the components has them counted and no holding @@ -52,4 +52,5 @@ values: - savings - other_residential_property_value - non_residential_property_value - - share_like_capital + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml index d1c1a17514..1563961bed 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml @@ -1,6 +1,7 @@ # corporate_wealth itemised into directly_held_shares, unit_and_investment_trusts -# and stocks_and_shares_isa, with unitemised_corporate_wealth carrying whatever -# is not itemised (max(0, corporate_wealth - the three components)). +# and stocks_and_shares_isa. share_like_capital_with_sale_expenses is the part +# valued less 10%: directly held shares plus anything not itemised (all of +# corporate_wealth when nothing is itemised). # Capital is valued at market value less 10% where a sale would incur expenses # (UC Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA # Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). Shares @@ -29,7 +30,7 @@ savings: 5_000 corporate_wealth: 12_000 output: - unitemised_corporate_wealth: 12_000 + share_like_capital_with_sale_expenses: 12_000 uc_assessable_capital: 15_800 is_uc_eligible: true # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. @@ -57,7 +58,7 @@ unit_and_investment_trusts: 3_000 stocks_and_shares_isa: 5_000 output: - unitemised_corporate_wealth: 0 + share_like_capital_with_sale_expenses: 4_000 uc_assessable_capital: 16_600 is_uc_eligible: false @@ -81,13 +82,13 @@ unit_and_investment_trusts: 2_600 stocks_and_shares_isa: 3_000 output: - unitemised_corporate_wealth: 0 + share_like_capital_with_sale_expenses: 10_000 uc_assessable_capital: 15_600 is_uc_eligible: true # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up): 39 * 4.35 * 12. uc_tariff_income: 2_035.8 -- name: UC partial split values the unitemised residual less 10% +- name: UC partial split values the part not itemised less 10% period: 2026 absolute_error_margin: 0.01 input: @@ -107,7 +108,7 @@ corporate_wealth: 12_000 stocks_and_shares_isa: 5_000 output: - unitemised_corporate_wealth: 7_000 + share_like_capital_with_sale_expenses: 7_000 uc_assessable_capital: 15_300 # 9,300 above 6,000 is 38 steps of 250 (37.2 rounded up): 38 * 4.35 * 12. uc_tariff_income: 1_983.6 @@ -133,7 +134,7 @@ directly_held_shares: 4_000 unit_and_investment_trusts: 3_000 output: - unitemised_corporate_wealth: 0 + share_like_capital_with_sale_expenses: 4_000 uc_assessable_capital: 7_600 # 1,600 above 6,000 is 7 steps of 250 (6.4 rounded up): 7 * 4.35 * 12. uc_tariff_income: 365.4 @@ -162,7 +163,7 @@ unit_and_investment_trusts: 4_000 stocks_and_shares_isa: 6_000 output: - unitemised_corporate_wealth: 0 + share_like_capital_with_sale_expenses: 5_000 housing_benefit_assessable_capital: 15_600 # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up) at 1 a week. housing_benefit_tariff_income: 2_028 diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 3194e95198..c66ba2dba1 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -1,14 +1,24 @@ -"""share_like_capital replaces corporate_wealth in the means-test capital lists. +"""Share-like capital in the means tests, itemised and valued by asset type. + +The capital lists name share_like_capital's two parts: +share_like_capital_with_sale_expenses (directly held shares, and all of an +unitemised corporate_wealth), which the sale-expense lists value less 10%, and +share_like_capital_without_sale_expenses (unit and investment trusts, stocks +and shares ISAs), valued in full. Invariants: 1. share_like_capital is corporate_wealth as supplied (any sign, any fraction) - unless the itemised components add up to more than it by more than the - rounding tolerance, in which case it is their sum. -2. Differential: for identical inputs that carry no components, or components - consistent with corporate_wealth, every programme's assessable capital and - eligibility equal those under the original lists (corporate_wealth), - exactly, annually and monthly. -3. A household given only the components has them counted. + unless the itemised components exceed it beyond the rounding tolerance, in + which case it is their sum. +2. With no itemised holdings, the part with sale expenses is all of + share_like_capital and the other part is exactly nil; otherwise the part + with sale expenses is share_like_capital less trusts and ISAs, floored at + nil, and the two parts add up to share_like_capital. +3. Differential: for identical unitemised inputs, every programme's assessable + capital and UC eligibility equal those under the lists before itemising + (corporate_wealth valued less 10%), exactly, annually and monthly. +4. Split effect: a consistent split raises a working-age single adult's UC + capital by 10% of trusts plus ISAs, against the unitemised lists. """ import numpy as np @@ -27,13 +37,18 @@ "unit_and_investment_trusts", "stocks_and_shares_isa", ) -SOURCE_LISTS = { - "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", - "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", - "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital.sources", - "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital.sources", - "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", - "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", +WITH, WITHOUT = ( + "share_like_capital_with_sale_expenses", + "share_like_capital_without_sale_expenses", +) +LEGAL_RATE = 0.1 +NODES = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", } YEAR = 2026 SETTINGS = settings( @@ -52,26 +67,30 @@ def _parameter(path): return node(f"{YEAR}-01-01") -ORIGINAL_LISTS = { - path: [ - "corporate_wealth" if source == "share_like_capital" else source - for source in _parameter(path) - ] - for path in SOURCE_LISTS.values() -} -ORIGINAL_REFORM = { - path: {str(YEAR): sources} for path, sources in ORIGINAL_LISTS.items() +def _unitemised(sources): + """The list before itemising: corporate_wealth where the share parts are.""" + out = [] + for source in sources: + if source == WITHOUT: + continue + out.append("corporate_wealth" if source == WITH else source) + return out + + +UNITEMISED_REFORM = { + f"{node}.{name}": {str(YEAR): _unitemised(_parameter(f"{node}.{name}"))} + for node in NODES.values() + for name in ("sources", "sale_expenses.sources") } money = st.floats(min_value=0, max_value=200_000, allow_nan=False, width=32) signed_money = st.floats(min_value=-5_000, max_value=200_000, allow_nan=False, width=32) -def _situation(households): - """One single-adult household per entry; ages alternate 30 and 70.""" +def _situation(households, age=None): people, benunits, hhs = {}, {}, {} for i, assets in enumerate(households): - people[f"p{i}"] = {"age": {YEAR: 70 if i % 2 else 30}} + people[f"p{i}"] = {"age": {YEAR: age or (70 if i % 2 else 30)}} benunits[f"b{i}"] = {"members": [f"p{i}"], "would_claim_uc": {YEAR: True}} hhs[f"h{i}"] = { "members": [f"p{i}"], @@ -80,28 +99,25 @@ def _situation(households): return {"people": people, "benunits": benunits, "households": hhs} -def _results(households, reform=None, period=YEAR): - sim = Simulation(situation=_situation(households), reform=reform) - out = {v: sim.calculate(v, period) for v in SOURCE_LISTS} +def _results(households, reform=None, period=YEAR, age=None): + sim = Simulation(situation=_situation(households, age), reform=reform) + out = {v: sim.calculate(v, period) for v in NODES} out["is_uc_eligible"] = sim.calculate("is_uc_eligible", period) - out["share_like_capital"] = sim.calculate("share_like_capital", YEAR) + for v in ("share_like_capital", WITH, WITHOUT): + out[v] = sim.calculate(v, YEAR) return out -def _assert_same_as_original(households): - for period in (YEAR, f"{YEAR}-01"): - new = _results(households, period=period) - old = _results(households, reform=ORIGINAL_REFORM, period=period) - for variable in (*SOURCE_LISTS, "is_uc_eligible"): - assert np.array_equal(new[variable], old[variable]), (variable, period) - - -@pytest.mark.parametrize("variable", SOURCE_LISTS) -def test_lists_name_share_like_capital_only(variable): - sources = list(_parameter(SOURCE_LISTS[variable])) - assert "share_like_capital" in sources - assert "corporate_wealth" not in sources - assert not set(COMPONENTS) & set(sources) +@pytest.mark.parametrize("variable", NODES) +def test_lists_name_the_two_parts(variable): + sources = list(_parameter(f"{NODES[variable]}.sources")) + sale = list(_parameter(f"{NODES[variable]}.sale_expenses.sources")) + assert WITH in sources and WITHOUT in sources + assert not {"corporate_wealth", "share_like_capital", *COMPONENTS} & set(sources) + assert WITH in sale and WITHOUT not in sale + assert not {"corporate_wealth", *COMPONENTS} & set(sale) + without = set(_parameter("gov.dwp.share_like_capital.without_sale_expenses")) + assert without == {"unit_and_investment_trusts", "stocks_and_shares_isa"} @SETTINGS @@ -112,62 +128,75 @@ def test_lists_name_share_like_capital_only(variable): max_size=40, ) ) -def test_share_like_capital_keeps_the_supplied_aggregate(rows): +def test_share_like_capital_and_its_parts(rows): households = [] for corporate_wealth, components, itemise in rows: assets = {"corporate_wealth": corporate_wealth} if itemise: assets |= dict(zip(COMPONENTS, components)) households.append(assets) - got = _results(households)["share_like_capital"] - for value, assets in zip(got, households): + got = _results(households) + for i, assets in enumerate(households): cw = np.float32(assets["corporate_wealth"]) - itemised = np.float32(sum(np.float32(assets.get(c, 0)) for c in COMPONENTS)) + values = [np.float32(assets.get(c, 0)) for c in COMPONENTS] + itemised = np.float32(sum(values)) tolerance = RELATIVE_TOLERANCE * abs(cw) + ABSOLUTE_TOLERANCE - expected = itemised if itemised > 0 and itemised > cw + tolerance else cw - assert value == pytest.approx(expected, rel=1e-6, abs=1e-3) - if not (itemised > 0 and itemised > cw + tolerance): - assert value == cw # exactly the supplied aggregate + exceeds = itemised > 0 and itemised > cw + tolerance + share_like = got["share_like_capital"][i] + if exceeds: + assert share_like == pytest.approx(itemised, rel=1e-6, abs=1e-3) + else: + assert share_like == cw + if itemised == 0: + assert got[WITH][i] == share_like and got[WITHOUT][i] == 0 + else: + expected = max(0.0, share_like - values[1] - values[2]) + assert got[WITH][i] == pytest.approx(expected, rel=1e-6, abs=1e-2) + assert got[WITH][i] + got[WITHOUT][i] == pytest.approx( + share_like, rel=1e-6, abs=1e-2 + ) @SETTINGS @given( - st.lists( - st.tuples(money, signed_money, st.tuples(money, money, money), st.booleans()), - min_size=1, - max_size=30, - ) + st.lists(st.tuples(money, signed_money), min_size=1, max_size=30), ) -def test_capital_matches_the_original_lists(rows): - """Unsplit, or split consistently: identical to listing corporate_wealth.""" - households = [] - for savings, corporate_wealth, components, split in rows: - assets = {"savings": savings, "corporate_wealth": corporate_wealth} - if split: - assets |= dict(zip(COMPONENTS, components)) - assets["corporate_wealth"] = float(sum(components)) - households.append(assets) - _assert_same_as_original(households) +def test_unitemised_inputs_match_the_lists_before_itemising(rows): + households = [{"savings": s, "corporate_wealth": cw} for s, cw in rows] + households += [ + {"savings": 6_001, "corporate_wealth": -2}, + {"savings": 0, "corporate_wealth": 17_777.78}, # 90% is £16,000.002 + ] + for period in (YEAR, f"{YEAR}-01"): + new = _results(households, period=period) + old = _results(households, reform=UNITEMISED_REFORM, period=period) + for variable in (*NODES, "is_uc_eligible"): + assert np.array_equal(new[variable], old[variable]), (variable, period) -def test_float32_rounding_at_the_limit_keeps_eligibility(): - """Components whose float32 sum overshoots £16,000 by a fraction of a penny.""" +@SETTINGS +@given( + st.lists(st.tuples(money, st.tuples(money, money, money)), min_size=1, max_size=30) +) +def test_a_consistent_split_exempts_trusts_and_isas(rows): households = [ { - "savings": 0, - "corporate_wealth": 16_000, - "directly_held_shares": 6_744.81, - "unit_and_investment_trusts": 6_581.24, - "stocks_and_shares_isa": 2_673.95, - }, - {"savings": 6_001, "corporate_wealth": -2}, - {"savings": 15_999.99, "corporate_wealth": 0.01}, + "savings": savings, + "corporate_wealth": float(sum(components)), + **dict(zip(COMPONENTS, components)), + } + for savings, components in rows ] - _assert_same_as_original(households) - assert _results(households)["share_like_capital"][0] == 16_000 + new = _results(households, age=30) + old = _results(households, reform=UNITEMISED_REFORM, age=30) + for i, (_, (_, trusts, isa)) in enumerate(rows): + exempt = np.float32(trusts) + np.float32(isa) + assert new["uc_assessable_capital"][i] - old["uc_assessable_capital"][ + i + ] == pytest.approx(LEGAL_RATE * exempt, rel=1e-5, abs=0.05) -def test_components_alone_are_counted(): +def test_components_alone_are_valued_by_type(): households = [ { "savings": 1_000, @@ -176,6 +205,8 @@ def test_components_alone_are_counted(): "stocks_and_shares_isa": 5_000, } ] - got = _results(households) + got = _results(households, age=30) assert got["share_like_capital"][0] == 12_000 - assert got["uc_assessable_capital"][0] == 13_000 + assert got[WITH][0] == 4_000 and got[WITHOUT][0] == 8_000 + # £1,000 + 90% of £4,000 + £3,000 + £5,000. + assert got["uc_assessable_capital"][0] == pytest.approx(12_600) diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 4bafb0c51d..0517d3181d 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -7,7 +7,9 @@ Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). A bank or building society account is valued at its balance (ADM H1675); land and property always have costs of sale (H1606) and shares -are valued less 10% (H1665). +are valued less 10% (H1665). The households here hold share-like wealth only +as unitemised corporate_wealth, which all takes the 10%; itemised holdings +(unit trusts and ISAs without it) are tested in test_capital_share_components. Invariants, for any generated population of single-adult households: @@ -57,14 +59,29 @@ } PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} # The assets whose sale incurs expenses, fixed here from the law rather than -# read from the parameters: real property always (ADM H1606), shares (H1665); -# never a bank or building society balance (H1675). +# read from the parameters: real property always (ADM H1606), shares (H1665), +# and unitemised corporate wealth with them; never a bank or building society +# balance (H1675). SALE_EXPENSE_ASSETS = { "owned_land", "other_residential_property_value", "non_residential_property_value", - "corporate_wealth", + "share_like_capital_with_sale_expenses", +} +# How each capital source reads an unitemised household's inputs: all of its +# corporate_wealth has expenses of sale, none is without. +SHARE_LIKE_SOURCES = { + "share_like_capital_with_sale_expenses": lambda assets: assets["corporate_wealth"], + "share_like_capital_without_sale_expenses": lambda assets: 0.0, } + + +def asset_value(unit, source): + if source in SHARE_LIKE_SOURCES: + return SHARE_LIKE_SOURCES[source](unit["assets"]) + return unit["assets"][source] + + LEGAL_RATE = 0.1 ASSETS = [ "savings", @@ -141,7 +158,7 @@ def expected_capital(unit, node): """Reg. 49(1) applied source by source, independently of the model.""" total = 0.0 for source in node.sources: - value = unit["assets"][source] + value = asset_value(unit, source) if source in SALE_EXPENSE_ASSETS: value *= 1 - LEGAL_RATE if source in DEBTS: @@ -179,7 +196,7 @@ def test_valuation_matches_the_regulation_and_never_adds_capital(units): assert close(valued[variable][i], expected), (variable, unit) # Debt secured on property never reaches the other assets. floor = sum( - unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + asset_value(unit, s) * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) for s in node.sources if s not in DEBTS ) diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index fe6f440ddf..92d4d82100 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -85,11 +85,12 @@ def test_itemised_share_like_stocks_are_not_prorated_monthly(): for name, value in stocks.items(): assert simulation.calculate(name, "2026-01")[0] == value, name - # 12,000 less the 9,000 itemised. - assert simulation.calculate("unitemised_corporate_wealth", "2026-01")[0] == ( - 3_000 - ) - # Shares and the unitemised residual less 10% for the expenses of + # 12,000 less the unit trusts and ISA (5,000): the 4,000 of shares + # and the 3,000 not itemised. + assert simulation.calculate("share_like_capital_with_sale_expenses", "2026-01")[ + 0 + ] == (7_000) + # Shares and the part not itemised less 10% for the expenses of # sale; unit trusts and the ISA at their withdrawal value: - # 1,000 + 3,600 + 3,000 + 2,000 + 2,700. + # 1,000 + 90% of 7,000 + 3,000 + 2,000 = 1,000 + 6,300 + 5,000. assert simulation.calculate(capital, "2026-01")[0] == 12_300, capital diff --git a/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py new file mode 100644 index 0000000000..596008d993 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + +COMPONENTS = [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +] + + +class share_like_capital_with_sale_expenses(Variable): + label = "share-like capital with expenses of sale" + documentation = ( + "The part of share_like_capital whose sale would incur expenses, so " + "that the means tests value it less 10%: share_like_capital less the " + "itemised holdings listed in gov.dwp.share_like_capital." + "without_sale_expenses (unit and investment trusts, stocks and shares " + "ISAs), floored at nil. A household with no itemised holdings has all " + "of its share-like capital here, so datasets that carry only " + "corporate_wealth keep the flat 10% on it." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + share_like = household("share_like_capital", period) + itemised = add(household, period, COMPONENTS) + without = add( + household, + period, + parameters(period).gov.dwp.share_like_capital.without_sale_expenses, + ) + return where(itemised > 0, max_(0, share_like - without), share_like) diff --git a/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py b/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py new file mode 100644 index 0000000000..ad777f2f0b --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class share_like_capital_without_sale_expenses(Variable): + label = "share-like capital without expenses of sale" + documentation = ( + "The part of share_like_capital the means tests value at full value: " + "share_like_capital less share_like_capital_with_sale_expenses. Nil " + "for a household with no itemised holdings." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + return household("share_like_capital", period) - household( + "share_like_capital_with_sale_expenses", period + ) From f64da7727707f13c1e8b6dd2ff820461a39ad5d5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 08:49:55 -0400 Subject: [PATCH 059/224] Count a component penny and bound the rounding tolerance by float32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review of 7784193a: with no corporate_wealth, a penny of shares fell inside the absolute tolerance and vanished, so UC eligibility at the £16,000 limit differed from the same penny entered as corporate_wealth; and 1e-5 relative plus a penny (£0.17 at £16,000, about 174 units in the last place) hid genuine excesses. The tolerance is now 1e-6 of the larger of corporate_wealth and the components' sum, against a float32 bound of about 3e-7 for a float64 sum of three components rounded and re-added in float32, and it is relative only, so a household with no corporate_wealth has any positive components counted in full. Tests: the component-winning case compares exactly against the engine's float32 sum; new independently specified cases for a component penny at the limit (same capital and UC eligibility as the penny entered as corporate_wealth, annually and monthly) and a genuine £0.10 excess; both fail under the old tolerance. The six list descriptions and the changelog state the qualified rule; the module claims are narrowed to UC eligibility and uprating metadata. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 29 +++++------ .../gov/dwp/JSA/income/capital/sources.yaml | 30 ++++++------ .../means_test/capital/sources.yaml | 31 ++++++------ .../means_test/capital/sources.yaml | 5 +- .../income/capital/sources.yaml | 6 +-- .../means_test/capital/sources.yaml | 47 +++++++++--------- .../tests/test_capital_share_components.py | 48 ++++++++++++++----- .../tests/test_wealth_components.py | 5 +- .../household/wealth/share_like_capital.py | 32 ++++++++----- 10 files changed, 136 insertions(+), 99 deletions(-) diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index a161d13f25..4a97f30c03 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count share-like capital in every means test as `corporate_wealth` or, where larger, the sum of its components (`share_like_capital`), and uprate the components and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count share-like capital in every means test as `corporate_wealth` or, where the components exceed it beyond float32 rounding, their sum (`share_like_capital`), and uprate the components and the ISA balances with per-capita GDP. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index a5de71cb3e..c9fd7d6e3f 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -8,20 +8,21 @@ description: > `non_residential_property_value` for other land and premises. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, - `stocks_and_shares_isa`) where that is larger, so a household given only the - components has them counted and no holding counts twice. - `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards - premises occupied as the claimant's home. `private_pension_wealth` is - excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the - right to receive an occupational or personal pension and the value of funds - held under a personal pension scheme. Other Schedule 9 disregards and the - higher residential-care capital disregard are not yet modeled because the - current dataset does not isolate those states or asset holdings. Because the - dataset only provides household-level capital stocks, the model allocates - the full household total to benunits with a reported income-related ESA - award and only falls back to an adult-share proxy when nobody in the - household is on that reported claim path. That allocation is a data - approximation, not a legislated ownership rule. + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. `main_residence_value` is excluded + because Schedule 9 paragraph 1 disregards premises occupied as the + claimant's home. `private_pension_wealth` is excluded because Schedule 9 + paragraphs 28 and 29 disregard the value of the right to receive an + occupational or personal pension and the value of funds held under a + personal pension scheme. Other Schedule 9 disregards and the higher + residential-care capital disregard are not yet modeled because the current + dataset does not isolate those states or asset holdings. Because the dataset + only provides household-level capital stocks, the model allocates the full + household total to benunits with a reported income-related ESA award and + only falls back to an adult-share proxy when nobody in the household is on + that reported claim path. That allocation is a data approximation, not a + legislated ownership rule. metadata: label: Income-related ESA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 7c5262e767..ab257cc8ce 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -8,21 +8,21 @@ description: > `non_residential_property_value` for other land and premises. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, - `stocks_and_shares_isa`) where that is larger, so a household given only the - components has them counted and no holding counts twice. - `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards - premises occupied as the claimant's home. `private_pension_wealth` is - excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the - right to receive an occupational or personal pension and the value of funds - held under a personal pension scheme. Other Schedule 8 disregards, the - higher residential-care capital disregard, and the separate child-capital - disregard are not yet modeled because the current dataset does not isolate - those states or asset holdings. Because the dataset only provides - household-level capital stocks, the model allocates the full household total - to benunits with a reported income-based JSA award and only falls back to an - adult-share proxy when nobody in the household is on that reported claim - path. That allocation is a data approximation, not a legislated ownership - rule. + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. `main_residence_value` is excluded + because Schedule 8 paragraph 1 disregards premises occupied as the + claimant's home. `private_pension_wealth` is excluded because Schedule 8 + paragraphs 28 and 29 disregard the value of the right to receive an + occupational or personal pension and the value of funds held under a + personal pension scheme. Other Schedule 8 disregards, the higher + residential-care capital disregard, and the separate child-capital disregard + are not yet modeled because the current dataset does not isolate those + states or asset holdings. Because the dataset only provides household-level + capital stocks, the model allocates the full household total to benunits + with a reported income-based JSA award and only falls back to an adult-share + proxy when nobody in the household is on that reported claim path. That + allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-based JSA countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index dbf6a86c0b..3bc1850dce 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -7,21 +7,22 @@ description: > property other than the home, and `non_residential_property_value` for other land and premises. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, - `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, - so a household given only the components has them counted and no holding - counts twice. `main_residence_value` is excluded because Schedule 6 - paragraph 1 disregards premises occupied as the claimant's home (paragraph - 26 of Schedule 6 to the pension-age regulations). `private_pension_wealth` - is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of - the right to receive an occupational or personal pension and the value of - funds held under a personal pension scheme (paragraph 24 of Schedule 6 to - the pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the - Northern Ireland regulations make the same disregard). Datasets built before - private pension wealth was split out of `corporate_wealth` still carry it - inside `share_like_capital`. Other Schedule 6 disregards within these broad - asset classes are not yet split out separately. Because the dataset only - provides household-level capital stocks, the model allocates them across - benunits using an adult-share proxy. + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it + beyond float32 rounding (see `share_like_capital`), so a household given + only the components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards + premises occupied as the claimant's home (paragraph 26 of Schedule 6 to the + pension-age regulations). `private_pension_wealth` is excluded because + Schedule 6 paragraphs 31 and 32 disregard the value of the right to receive + an occupational or personal pension and the value of funds held under a + personal pension scheme (paragraph 24 of Schedule 6 to the pension-age + regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland + regulations make the same disregard). Datasets built before private pension + wealth was split out of `corporate_wealth` still carry it inside + `share_like_capital`. Other Schedule 6 disregards within these broad asset + classes are not yet split out separately. Because the dataset only provides + household-level capital stocks, the model allocates them across benunits + using an adult-share proxy. metadata: label: Housing Benefit countable capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 971341aa06..f2e8658444 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -5,8 +5,9 @@ description: > pension and the value of funds held under a personal pension scheme. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, `unit_and_investment_trusts`, - `stocks_and_shares_isa`) where that is larger, so a household given only the - components has them counted and no holding counts twice. + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. metadata: label: Income Support capital sources period: year diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index b567570949..93343e1586 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -7,9 +7,9 @@ description: > contract; the notional income rule for undrawn money purchase pots (regulation 18) is not modelled. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, - `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, - so a household given only the components has them counted and no holding - counts twice. + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it + beyond float32 rounding (see `share_like_capital`), so a household given + only the components has them counted and no holding counts twice. metadata: label: Pension Credit capital sources unit: list diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index c4615ccf9c..0536b099a1 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -7,29 +7,30 @@ description: > home, `non_residential_property_value` for other land and premises, and the share-like sources. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, - `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, - so a household given only the components has them counted and no holding - counts twice. `main_residence_value` is excluded because Schedule 10 - paragraph 1 disregards premises occupied as the person's home. - `private_pension_wealth` is excluded because Schedule 10 paragraph 10 - disregards the value of any right to receive a pension under an occupational - or personal pension scheme (or any other scheme registered under section 153 - of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland - regulations makes the same disregard); pension income actually drawn counts - as unearned income instead; datasets built before it was split out of - `corporate_wealth` still carry it inside `share_like_capital`. Other - Schedule 10 disregards within these broad asset classes are not yet split - out separately. Where only household-level capital is available for a - multi-benunit household, the model uses a household-wide adult-share proxy - for residual capital because the dataset does not identify which adults hold - the beneficial interest in each capital asset. The denominator is all - unreported adults in the household, including adults in benunits that are - not themselves UC claimants. That proxy is a data approximation, not a - legislated allocation rule. Regulation 47 is cited only as related context - on jointly held capital. If some benunits provide `uc_reported_capital` (by - default the benefit unit's recorded capital, `benunit_reported_capital`), - the proxy is applied only to the residual household capital for benunits - that remain unreported. + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it + beyond float32 rounding (see `share_like_capital`), so a household given + only the components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 10 paragraph 1 + disregards premises occupied as the person's home. `private_pension_wealth` + is excluded because Schedule 10 paragraph 10 disregards the value of any + right to receive a pension under an occupational or personal pension scheme + (or any other scheme registered under section 153 of the Finance Act 2004; + paragraph 10 of Schedule 10 to the Northern Ireland regulations makes the + same disregard); pension income actually drawn counts as unearned income + instead; datasets built before it was split out of `corporate_wealth` still + carry it inside `share_like_capital`. Other Schedule 10 disregards within + these broad asset classes are not yet split out separately. Where only + household-level capital is available for a multi-benunit household, the + model uses a household-wide adult-share proxy for residual capital because + the dataset does not identify which adults hold the beneficial interest in + each capital asset. The denominator is all unreported adults in the + household, including adults in benunits that are not themselves UC + claimants. That proxy is a data approximation, not a legislated allocation + rule. Regulation 47 is cited only as related context on jointly held + capital. If some benunits provide `uc_reported_capital` (by default the + benefit unit's recorded capital, `benunit_reported_capital`), the proxy is + applied only to the residual household capital for benunits that remain + unreported. metadata: label: Universal Credit countable capital sources period: year diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 3194e95198..10aaf1fbf2 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -2,13 +2,13 @@ Invariants: 1. share_like_capital is corporate_wealth as supplied (any sign, any fraction) - unless the itemised components add up to more than it by more than the - rounding tolerance, in which case it is their sum. + unless the itemised components are positive and exceed it by more than the + float32 rounding tolerance, in which case it is exactly their sum. 2. Differential: for identical inputs that carry no components, or components - consistent with corporate_wealth, every programme's assessable capital and - eligibility equal those under the original lists (corporate_wealth), + consistent with corporate_wealth, every programme's assessable capital, and + UC eligibility, equal those under the original lists (corporate_wealth), exactly, annually and monthly. -3. A household given only the components has them counted. +3. A household given only the components has them counted, down to a penny. """ import numpy as np @@ -18,7 +18,6 @@ from policyengine_uk import CountryTaxBenefitSystem, Simulation from policyengine_uk.variables.household.wealth.share_like_capital import ( - ABSOLUTE_TOLERANCE, RELATIVE_TOLERANCE, ) @@ -122,11 +121,13 @@ def test_share_like_capital_keeps_the_supplied_aggregate(rows): got = _results(households)["share_like_capital"] for value, assets in zip(got, households): cw = np.float32(assets["corporate_wealth"]) - itemised = np.float32(sum(np.float32(assets.get(c, 0)) for c in COMPONENTS)) - tolerance = RELATIVE_TOLERANCE * abs(cw) + ABSOLUTE_TOLERANCE - expected = itemised if itemised > 0 and itemised > cw + tolerance else cw - assert value == pytest.approx(expected, rel=1e-6, abs=1e-3) - if not (itemised > 0 and itemised > cw + tolerance): + itemised = np.float32(0) + for component in COMPONENTS: + itemised = np.float32(itemised + np.float32(assets.get(component, 0))) + tolerance = RELATIVE_TOLERANCE * max(abs(cw), itemised) + if itemised > 0 and itemised > cw + tolerance: + assert value == itemised # exactly the components' sum + else: assert value == cw # exactly the supplied aggregate @@ -167,6 +168,31 @@ def test_float32_rounding_at_the_limit_keeps_eligibility(): assert _results(households)["share_like_capital"][0] == 16_000 +def test_a_component_penny_at_the_limit_counts(): + """A penny of shares with no corporate_wealth counts like a penny of it.""" + as_component = [{"savings": 16_000, "directly_held_shares": 0.01}] + as_aggregate = [{"savings": 16_000, "corporate_wealth": 0.01}] + for period in (YEAR, f"{YEAR}-01"): + component = _results(as_component, period=period) + aggregate = _results(as_aggregate, period=period) + assert component["share_like_capital"][0] == np.float32(0.01) + for variable in (*SOURCE_LISTS, "is_uc_eligible"): + assert np.array_equal(component[variable], aggregate[variable]), variable + assert not component["is_uc_eligible"][0] + + +def test_a_genuine_excess_over_the_aggregate_counts(): + """Ten pence of components above the aggregate is a holding, not rounding.""" + households = [ + { + "corporate_wealth": 16_000, + "directly_held_shares": 10_000.10, + "stocks_and_shares_isa": 6_000, + } + ] + assert _results(households)["share_like_capital"][0] == np.float32(16_000.10) + + def test_components_alone_are_counted(): households = [ { diff --git a/policyengine_uk/tests/test_wealth_components.py b/policyengine_uk/tests/test_wealth_components.py index 98e4799477..489d6ac0f8 100644 --- a/policyengine_uk/tests/test_wealth_components.py +++ b/policyengine_uk/tests/test_wealth_components.py @@ -1,5 +1,6 @@ -"""corporate_wealth's components share its entity, quantity type and uprating, so -a dataset's identity corporate_wealth == sum of components survives projection.""" +"""corporate_wealth's components share its entity, quantity type and uprating +metadata (class uprating and uprating_indices.yaml index), the condition for a +dataset's identity corporate_wealth == sum of components to survive projection.""" from pathlib import Path diff --git a/policyengine_uk/variables/household/wealth/share_like_capital.py b/policyengine_uk/variables/household/wealth/share_like_capital.py index 967be954db..546b97c13f 100644 --- a/policyengine_uk/variables/household/wealth/share_like_capital.py +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -1,9 +1,14 @@ from policyengine_uk.model_api import * -# Itemised holdings within this much of corporate_wealth are the same holdings -# rounded differently (float32 sums of the components), not extra wealth. -RELATIVE_TOLERANCE = 1e-5 -ABSOLUTE_TOLERANCE = 0.01 +# Itemised holdings within this share of the larger of corporate_wealth and +# their sum are the same holdings rounded differently, not extra wealth. +# Simulations hold each value as float32 (24-bit significand, spacing 2^-23 of +# the value): a dataset that builds corporate_wealth as the float64 sum of +# three components and stores all four rounds each once, and the engine adds +# the three components in float32, so the two totals differ by at most about +# 2.5 units in the last place, 3e-7 of the value. 1e-6 leaves a margin of more +# than three times that and still counts any genuine excess above a millionth. +RELATIVE_TOLERANCE = 1e-6 class share_like_capital(Variable): @@ -12,14 +17,15 @@ class share_like_capital(Variable): "Share-like holdings counted by the means-test capital tests: " "corporate_wealth as supplied, unless its itemised components " "(directly_held_shares, unit_and_investment_trusts and " - "stocks_and_shares_isa) add up to more than it, in which case their sum. " - "So datasets that carry only corporate_wealth, or build it as the " - "components' sum, count exactly corporate_wealth (any sign), and a " - "household given only the components has them counted. A gap within " - "a hundred-thousandth of corporate_wealth plus a penny is treated as " - "rounding, so the supplied aggregate is kept. Private pension wealth is " - "not part of it; datasets built before private pension wealth was " - "split out of corporate_wealth still carry it here." + "stocks_and_shares_isa) are positive and add up to more than it by more " + "than a millionth of the larger of the two, in which case their sum. " + "Datasets that carry only corporate_wealth, or build it as the " + "components' sum, therefore count exactly corporate_wealth (any sign): " + "the millionth absorbs float32 rounding of the sum. A household given " + "only components, with no corporate_wealth, has them counted in full. " + "Private pension wealth is not part of it; datasets built before " + "private pension wealth was split out of corporate_wealth still carry " + "it here." ) entity = Household definition_period = YEAR @@ -38,6 +44,6 @@ def formula(household, period, parameters): "stocks_and_shares_isa", ], ) - tolerance = RELATIVE_TOLERANCE * abs(corporate_wealth) + ABSOLUTE_TOLERANCE + tolerance = RELATIVE_TOLERANCE * max_(abs(corporate_wealth), itemised) exceeds = (itemised > 0) & (itemised > corporate_wealth + tolerance) return where(exceeds, itemised, corporate_wealth) From 10c54a5962176f3fb5befa757793d121ce459077 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 08:50:32 -0400 Subject: [PATCH 060/224] Start the exempt list in 1996, sum it from zero, and test float32 grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of b7e6bc5c5: - gov.dwp.share_like_capital.without_sale_expenses started in 2013, after the legacy capital lists (JSA from 1996-10-07), so a 2012 simulation with unitemised corporate_wealth raised ParameterNotFoundError; it now starts at 1996-10-07, and Income Support and JSA capital in 2012 are tested (both fail with the 2013 start). - An empty exempt list made add() return None; the exempt holdings are now summed from a numeric zero, and an empty-list reform (every share-like holding less 10%) is tested. - The property oracle now follows the engine's float32 grouping (components left to right, exempt holdings summed before subtraction) and compares exactly; the reviewer's example (trusts £200,000, ISA £199,999.984375) is pinned. Co-Authored-By: Claude Opus 5.5 --- .../without_sale_expenses.yaml | 2 +- .../tests/test_capital_share_components.py | 67 ++++++++++++++++--- .../share_like_capital_with_sale_expenses.py | 12 ++-- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml index 086d7d849c..fa06ccc205 100644 --- a/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml +++ b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml @@ -24,6 +24,6 @@ metadata: - title: HMRC Annual savings statistics 2026, ISA Table 9.6 href: https://www.gov.uk/government/statistics/annual-savings-statistics-2026 values: - 2013-04-29: + 1996-10-07: - unit_and_investment_trusts - stocks_and_shares_isa diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index c66ba2dba1..c848efc0a8 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -28,7 +28,6 @@ from policyengine_uk import CountryTaxBenefitSystem, Simulation from policyengine_uk.variables.household.wealth.share_like_capital import ( - ABSOLUTE_TOLERANCE, RELATIVE_TOLERANCE, ) @@ -139,22 +138,68 @@ def test_share_like_capital_and_its_parts(rows): for i, assets in enumerate(households): cw = np.float32(assets["corporate_wealth"]) values = [np.float32(assets.get(c, 0)) for c in COMPONENTS] - itemised = np.float32(sum(values)) - tolerance = RELATIVE_TOLERANCE * abs(cw) + ABSOLUTE_TOLERANCE - exceeds = itemised > 0 and itemised > cw + tolerance + # The engine's float32 grouping: components added left to right, the + # exempt holdings summed before they are subtracted. + itemised = np.float32(np.float32(values[0] + values[1]) + values[2]) + exempt = np.float32(values[1] + values[2]) + tolerance = RELATIVE_TOLERANCE * max(abs(cw), itemised) share_like = got["share_like_capital"][i] - if exceeds: - assert share_like == pytest.approx(itemised, rel=1e-6, abs=1e-3) + if itemised > 0 and itemised > cw + tolerance: + assert share_like == itemised else: assert share_like == cw if itemised == 0: assert got[WITH][i] == share_like and got[WITHOUT][i] == 0 else: - expected = max(0.0, share_like - values[1] - values[2]) - assert got[WITH][i] == pytest.approx(expected, rel=1e-6, abs=1e-2) - assert got[WITH][i] + got[WITHOUT][i] == pytest.approx( - share_like, rel=1e-6, abs=1e-2 - ) + assert got[WITH][i] == max(np.float32(0), np.float32(share_like - exempt)) + assert got[WITHOUT][i] == np.float32(share_like - got[WITH][i]) + + +def test_float32_grouping_of_the_exempt_holdings(): + """Trusts and ISA summed first: £400,000 less £399,999.98 rounds to nil.""" + households = [ + { + "corporate_wealth": 399_999.984375, + "unit_and_investment_trusts": 200_000, + "stocks_and_shares_isa": 199_999.984375, + } + ] + got = _results(households) + assert got[WITH][0] == 0 + + +def test_an_empty_exempt_list_values_everything_less_10_percent(): + """A ruling that every share-like holding takes the 10% is a parameter edit.""" + reform = {"gov.dwp.share_like_capital.without_sale_expenses": {str(YEAR): []}} + households = [ + {"savings": 1_000, "corporate_wealth": 12_000}, + { + "savings": 1_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 5_000, + }, + ] + got = _results(households, reform=reform, age=30) + assert list(got[WITH]) == [12_000, 12_000] + assert list(got[WITHOUT]) == [0, 0] + # £1,000 + 90% of £12,000 either way. + assert got["uc_assessable_capital"] == pytest.approx([11_800, 11_800]) + + +@pytest.mark.parametrize( + "variable", ["income_support_assessable_capital", "jsa_income_assessable_capital"] +) +def test_legacy_capital_before_universal_credit(variable): + """2012: the legacy lists apply, and so does the exempt list (from 1996).""" + situation = _situation([{"savings": 1_000, "corporate_wealth": 12_000}], age=30) + for entity in situation.values(): + for name, values in entity.items(): + for key, by_year in list(values.items()): + if isinstance(by_year, dict) and YEAR in by_year: + values[key] = {2012: by_year[YEAR]} + capital = Simulation(situation=situation).calculate(variable, 2012)[0] + assert capital == pytest.approx(11_800) @SETTINGS diff --git a/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py index 596008d993..d5136dd2b3 100644 --- a/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py +++ b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py @@ -27,9 +27,11 @@ class share_like_capital_with_sale_expenses(Variable): def formula(household, period, parameters): share_like = household("share_like_capital", period) itemised = add(household, period, COMPONENTS) - without = add( - household, - period, - parameters(period).gov.dwp.share_like_capital.without_sale_expenses, - ) + # Summed from a numeric zero so that an empty list (every holding + # taking the deduction) still works. + without = 0 * share_like + for variable in parameters( + period + ).gov.dwp.share_like_capital.without_sale_expenses: + without = without + household(variable, period) return where(itemised > 0, max_(0, share_like - without), share_like) From 365f642d8ccb8fedcd8b9bdacc9db77af9c5de0e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 09:22:21 -0400 Subject: [PATCH 061/224] Compare the rounding gap exactly and pin the tolerance from below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review of f64da772 (APPROVE WITH NITS): - N2: the tests pinned the tolerance from above only. A new case uses pence-valued components (£16,015.98 + £286.98 + £81.05) whose float32 sum lands one unit in the last place (£0.00195) above £16,384.01: more than 1e-7 of the value, less than 1e-6. It exposed that comparing itemised > corporate_wealth + tolerance in float32 rounds the tolerance up to a whole unit; the formula now compares the difference, which float32 computes exactly for close values, and a 1e-7 tolerance now fails the case. Property-test components are drawn as pounds and pence. - N4: the differential now also runs a projected year (2027, annual and monthly), with the original lists restored for 2026-2030. - N1: the error-bound comment states it assumes float64 dataset tables. - N5: stocks_and_shares_isa says it is part of corporate_wealth. N3 (a microcosm export where corporate_wealth falls below the ISA) does not apply to microcosm main: corporate_wealth there is corporate_wealth_excl_isa plus the ISA, both drawn non-negative. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_capital_share_components.py | 36 ++++++++++++++++--- .../household/wealth/share_like_capital.py | 8 ++++- .../household/wealth/stocks_and_shares_isa.py | 3 +- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 10aaf1fbf2..40619efe2b 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -59,11 +59,16 @@ def _parameter(path): for path in SOURCE_LISTS.values() } ORIGINAL_REFORM = { - path: {str(YEAR): sources} for path, sources in ORIGINAL_LISTS.items() + path: {f"{YEAR}-01-01.{YEAR + 4}-12-31": sources} + for path, sources in ORIGINAL_LISTS.items() } -money = st.floats(min_value=0, max_value=200_000, allow_nan=False, width=32) -signed_money = st.floats(min_value=-5_000, max_value=200_000, allow_nan=False, width=32) +# Pounds and pence, as a dataset stores them in float64; the engine rounds +# each to float32, which is the rounding the tolerance has to absorb. +money = st.integers(min_value=0, max_value=20_000_000).map(lambda pence: pence / 100) +signed_money = st.integers(min_value=-500_000, max_value=20_000_000).map( + lambda pence: pence / 100 +) def _situation(households): @@ -88,7 +93,8 @@ def _results(households, reform=None, period=YEAR): def _assert_same_as_original(households): - for period in (YEAR, f"{YEAR}-01"): + # A projected year as well, where the inputs are uprated. + for period in (YEAR, f"{YEAR}-01", YEAR + 1, f"{YEAR + 1}-01"): new = _results(households, period=period) old = _results(households, reform=ORIGINAL_REFORM, period=period) for variable in (*SOURCE_LISTS, "is_uc_eligible"): @@ -125,7 +131,7 @@ def test_share_like_capital_keeps_the_supplied_aggregate(rows): for component in COMPONENTS: itemised = np.float32(itemised + np.float32(assets.get(component, 0))) tolerance = RELATIVE_TOLERANCE * max(abs(cw), itemised) - if itemised > 0 and itemised > cw + tolerance: + if itemised > 0 and itemised - cw > tolerance: assert value == itemised # exactly the components' sum else: assert value == cw # exactly the supplied aggregate @@ -168,6 +174,26 @@ def test_float32_rounding_at_the_limit_keeps_eligibility(): assert _results(households)["share_like_capital"][0] == 16_000 +def test_one_unit_of_float32_rounding_above_a_power_of_two_keeps_the_aggregate(): + """Pence-valued components whose float32 sum lands one unit in the last place + (£0.00195) above £16,384.01: more than 1e-7 of the value, less than 1e-6.""" + households = [ + { + "savings": 0, + "corporate_wealth": 16_384.01, + "directly_held_shares": 16_015.98, + "unit_and_investment_trusts": 286.98, + "stocks_and_shares_isa": 81.05, + } + ] + itemised = np.float32( + np.float32(np.float32(16_015.98) + np.float32(286.98)) + np.float32(81.05) + ) + assert itemised - np.float32(16_384.01) == np.float32(0.001953125) + assert _results(households)["share_like_capital"][0] == np.float32(16_384.01) + _assert_same_as_original(households) + + def test_a_component_penny_at_the_limit_counts(): """A penny of shares with no corporate_wealth counts like a penny of it.""" as_component = [{"savings": 16_000, "directly_held_shares": 0.01}] diff --git a/policyengine_uk/variables/household/wealth/share_like_capital.py b/policyengine_uk/variables/household/wealth/share_like_capital.py index 546b97c13f..96c6eabb72 100644 --- a/policyengine_uk/variables/household/wealth/share_like_capital.py +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -8,6 +8,9 @@ # the three components in float32, so the two totals differ by at most about # 2.5 units in the last place, 3e-7 of the value. 1e-6 leaves a margin of more # than three times that and still counts any genuine excess above a millionth. +# The bound assumes the dataset stores float64 tables, as policyengine-uk-data +# and microcosm do; float32 tables would add a rounding per side for each year +# a dataset is projected forward. RELATIVE_TOLERANCE = 1e-6 @@ -45,5 +48,8 @@ def formula(household, period, parameters): ], ) tolerance = RELATIVE_TOLERANCE * max_(abs(corporate_wealth), itemised) - exceeds = (itemised > 0) & (itemised > corporate_wealth + tolerance) + # Compare the difference, which float32 computes exactly for close + # values, rather than adding the tolerance to corporate_wealth, which + # would round it up to a whole unit in the last place. + exceeds = (itemised > 0) & (itemised - corporate_wealth > tolerance) return where(exceeds, itemised, corporate_wealth) diff --git a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py index 5c9b4d6ccb..caa743d61e 100644 --- a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py +++ b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py @@ -4,7 +4,8 @@ class stocks_and_shares_isa(Variable): label = "stocks and shares ISA holdings" documentation = ( - "Amount held in stocks and shares (investment) Individual Savings Accounts" + "Amount held in stocks and shares (investment) Individual Savings " + "Accounts. Part of corporate_wealth, so the two must never be summed." ) entity = Household definition_period = YEAR From facdab83d4e68e8169bcdc95bc12328e8fb9b32b Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Wed, 14 Oct 2026 16:22:05 +0200 Subject: [PATCH 062/224] 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 063/224] 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 064/224] 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 86c380be6f394b0c8d4b71a68dd183701afd7f2a 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 19:41:53 +0100 Subject: [PATCH 065/224] Update Welsh residential Land Transaction Tax rates --- changelog.d/1796.fixed.md | 1 + .../residential/README.md | 46 ++++- .../residential/higher_rate.yaml | 9 + .../residential/primary.yaml | 7 + .../residential_rate_changes.yaml | 166 ++++++++++++++++++ .../tests/test_welsh_residential_ltt_rates.py | 147 ++++++++++++++++ 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1796.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml create mode 100644 policyengine_uk/tests/test_welsh_residential_ltt_rates.py diff --git a/changelog.d/1796.fixed.md b/changelog.d/1796.fixed.md new file mode 100644 index 0000000000..8d18512306 --- /dev/null +++ b/changelog.d/1796.fixed.md @@ -0,0 +1 @@ +- Updated Welsh residential Land Transaction Tax tables for annual model years from 2023 (main rates) and 2025 (higher rates), retaining their statutory change dates. diff --git a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md index a95cd5ccd6..033530c048 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md @@ -1 +1,45 @@ -# Residential +# Residential Land Transaction Tax + +The scales apply marginal rates to the consideration for each purchase. The +main and additional-property purchase inputs select the main and higher tables +respectively; the final Land Transaction Tax charge applies only in Wales. + +[WSI 2022/1027, regulation 7](https://www.legislation.gov.uk/wsi/2022/1027/regulation/7/made) +replaced the main residential table from 10 October 2022: + +| Portion of consideration | Rate | +| --- | ---: | +| Up to £225,000 | 0% | +| Over £225,000 to £400,000 | 6% | +| Over £400,000 to £750,000 | 7.5% | +| Over £750,000 to £1,500,000 | 10% | +| Over £1,500,000 | 12% | + +[WSI 2024/1311, regulation 6](https://www.legislation.gov.uk/wsi/2024/1311/regulation/6/made) +replaced the higher residential table from 11 December 2024: + +| Portion of consideration | Rate | +| --- | ---: | +| Up to £180,000 | 5% | +| Over £180,000 to £250,000 | 8.5% | +| Over £250,000 to £400,000 | 10% | +| Over £400,000 to £750,000 | 12.5% | +| Over £750,000 to £1,500,000 | 15% | +| Over £1,500,000 | 17% | + +The raw parameter files retain these statutory dates and the preceding history. +The annual model samples transactional parameters on 30 April. Therefore the +main table changes in model year 2023, while the higher table changes in model +year 2025. Model years 2022 and 2024 retain their respective April tables; +neither year blends the old and new rates. + +The annual Household purchase inputs have no transaction date, contract date, +election or subsequent-contract-event fields. They cannot select a transaction +later in the change year or model protected old contracts. Under +[the 2022 regulations, regulations 4–6](https://www.legislation.gov.uk/wsi/2022/1027/made), +qualifying earlier contracts require an election which must not increase tax. +Under [the 2024 regulations, regulations 4–5](https://www.legislation.gov.uk/wsi/2024/1311/made), +qualifying earlier contracts are protected without an election requirement. +Both provisions have conditions and exclusions; an earlier contract alone is +not sufficient in every case. These transaction-specific provisions are outside +the annual input contract. diff --git a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml index 3729a95570..f3d47ece70 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml @@ -2,31 +2,37 @@ brackets: - rate: 2018-04-01: 0.03 2020-12-22: 0.04 + 2024-12-11: 0.05 threshold: 2018-04-01: 0 - rate: 2018-04-01: 0.065 2020-12-22: 0.075 + 2024-12-11: 0.085 threshold: 2018-04-01: 180000 - rate: 2018-04-01: 0.08 2020-12-22: 0.09 + 2024-12-11: 0.1 threshold: 2018-04-01: 250000 - rate: 2018-04-01: 0.105 2020-12-22: 0.115 + 2024-12-11: 0.125 threshold: 2018-04-01: 400000 - rate: 2018-04-01: 0.13 2020-12-22: 0.14 + 2024-12-11: 0.15 threshold: 2018-04-01: 750000 - rate: 2018-04-01: 0.15 2020-12-22: 0.16 + 2024-12-11: 0.17 threshold: 2018-04-01: 1500000 description: Higher residential property progressive tax scale for non-primary residences. @@ -42,4 +48,7 @@ metadata: - href: https://www.legislation.gov.uk/wsi/2020/1618/regulation/6/made title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) Regulations 2020 + - href: https://www.legislation.gov.uk/wsi/2024/1311/regulation/6/made + title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) + Regulations 2024, regulation 6, Table 2 threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml index 627eb0fb87..e1856312e3 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml @@ -7,14 +7,18 @@ brackets: 2018-04-01: 0.035 2020-07-27: null 2021-07-01: 0.035 + 2022-10-10: 0.06 threshold: 2018-04-01: 180000 2020-07-27: null 2021-07-01: 180000 + 2022-10-10: 225000 - rate: 2018-04-01: 0.05 + 2022-10-10: null threshold: 2018-04-01: 250000 + 2022-10-10: null - rate: 2018-04-01: 0.075 threshold: @@ -43,4 +47,7 @@ metadata: - href: https://www.legislation.gov.uk/wsi/2021/238/regulation/2/made title: The Land Transaction Tax (Temporary Variation of Rates and Bands for Residential Property Transactions) (Wales) (Amendment) Regulations 2021 + - href: https://www.legislation.gov.uk/wsi/2022/1027/regulation/7/made + title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) + Regulations 2022, regulation 7, Table 1 threshold_unit: currency-GBP diff --git a/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml new file mode 100644 index 0000000000..c3149c6685 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml @@ -0,0 +1,166 @@ +# Literal marginal-band sums from WSI 2022/1027 reg 7 and WSI 2024/1311 reg 6. +# Annual parameters sample 30 April; these are not dated/protected-contract cases. +- name: Main purchase retains the April 2022 table + period: 2022 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 4_950 + +- name: Main purchase uses the new table in annual 2023 + period: 2023 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 4_500 + +- name: Main purchase at 400000 retains the April 2022 table + period: 2022 + input: + age: 45 + region: WALES + main_residential_property_purchased: 400_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 9_950 + +- name: New main table raises tax at the 400000 boundary + period: 2023 + input: + age: 45 + region: WALES + main_residential_property_purchased: 400_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 10_500 + +- name: Additional purchase retains the April 2024 table + period: 2024 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 16_950 + +- name: Additional purchase uses the new table in annual 2025 + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 19_950 + +- name: Distinct main and additional purchases use their respective tables + period: 2026 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 24_450 + +- name: Main purchase reaches every current residential band + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 2_000_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 171_750 + +- name: Additional purchase reaches every current higher band + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 2_000_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 271_200 + +- name: No purchase leaves Welsh LTT at zero + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 0 + +- name: English purchases do not incur Welsh LTT + period: 2025 + input: + age: 45 + region: LONDON + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 0 diff --git a/policyengine_uk/tests/test_welsh_residential_ltt_rates.py b/policyengine_uk/tests/test_welsh_residential_ltt_rates.py new file mode 100644 index 0000000000..ce9ce4e6f7 --- /dev/null +++ b/policyengine_uk/tests/test_welsh_residential_ltt_rates.py @@ -0,0 +1,147 @@ +"""Welsh residential LTT dates and the existing annual sampling convention. + +WSI 2022/1027 reg 7 replaces the main table from 10 October 2022; +WSI 2024/1311 reg 6 replaces the higher table from 11 December 2024. +Annual purchase inputs cannot select transaction dates or protected contracts. +""" + +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.parameters import ParameterScale + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + +PATH = "gov.wra.land_transaction_tax.residential" +DIRECTORY = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/wra/land_transaction_tax/residential" +) +OLD_THRESHOLDS = [0, 180_000, 250_000, 400_000, 750_000, 1_500_000] +MAIN_THRESHOLDS = [0, 225_000, 400_000, 750_000, 1_500_000] +OLD_MAIN_RATES = [0, 0.035, 0.05, 0.075, 0.1, 0.12] +MAIN_RATES = [0, 0.06, 0.075, 0.1, 0.12] +OLD_HIGHER_RATES = [0.04, 0.075, 0.09, 0.115, 0.14, 0.16] +HIGHER_RATES = [0.05, 0.085, 0.1, 0.125, 0.15, 0.17] + + +def raw_scale(name): + file = DIRECTORY / f"{name}.yaml" + data = yaml.safe_load(file.read_text()) + for bracket in data["brackets"]: + for component in ["threshold", "rate"]: + bracket[component] = { + str(date): value for date, value in bracket[component].items() + } + return ParameterScale(f"{PATH}.{name}", data=data, file_path=str(file)) + + +@pytest.mark.parametrize( + "date,thresholds,rates", + [ + ("2018-04-01", OLD_THRESHOLDS, OLD_MAIN_RATES), + ( + "2020-07-27", + [0, 250_000, 400_000, 750_000, 1_500_000], + [0, 0.05, 0.075, 0.1, 0.12], + ), + ("2021-07-01", OLD_THRESHOLDS, OLD_MAIN_RATES), + ("2022-10-09", OLD_THRESHOLDS, OLD_MAIN_RATES), + ("2022-10-10", MAIN_THRESHOLDS, MAIN_RATES), + ], +) +def test_raw_main_table_preserves_history_and_october_change(date, thresholds, rates): + raw = raw_scale("primary") + assert raw(date).thresholds == thresholds + assert raw(date).rates == rates + assert not raw.metadata.get("fiscal_year_blend", False) + assert not raw.metadata.get("preserve_calendar_dates", False) + + +@pytest.mark.parametrize( + "date,rates", + [ + ("2018-04-01", [0.03, 0.065, 0.08, 0.105, 0.13, 0.15]), + ("2020-12-21", [0.03, 0.065, 0.08, 0.105, 0.13, 0.15]), + ("2020-12-22", OLD_HIGHER_RATES), + ("2024-12-10", OLD_HIGHER_RATES), + ("2024-12-11", HIGHER_RATES), + ], +) +def test_raw_higher_table_preserves_history_and_december_change(date, rates): + raw = raw_scale("higher_rate") + assert raw(date).thresholds == OLD_THRESHOLDS + assert raw(date).rates == rates + + +@pytest.mark.parametrize( + "name,date,expected", + [ + ( + "primary", + "2022-10-10", + [0, 0, 0, 1_500, 4_500, 10_500, 18_000, 36_750, 41_750, 111_750, 171_750], + ), + ( + "higher_rate", + "2024-12-11", + [ + 0, + 9_000, + 12_825, + 14_950, + 19_950, + 29_950, + 42_450, + 73_700, + 81_200, + 186_200, + 271_200, + ], + ), + ], +) +def test_new_tables_apply_marginal_rates_across_all_bands(name, date, expected): + prices = np.array( + [ + 0, + 180_000, + 225_000, + 250_000, + 300_000, + 400_000, + 500_000, + 750_000, + 800_000, + 1_500_000, + 2_000_000, + ] + ) + np.testing.assert_allclose( + raw_scale(name)(date).calc(prices), expected, rtol=0, atol=1e-8 + ) + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "name,year,thresholds,rates", + [ + ("primary", 2022, OLD_THRESHOLDS, OLD_MAIN_RATES), + ("primary", 2023, MAIN_THRESHOLDS, MAIN_RATES), + ("higher_rate", 2024, OLD_THRESHOLDS, OLD_HIGHER_RATES), + ("higher_rate", 2025, OLD_THRESHOLDS, HIGHER_RATES), + ], +) +def test_annual_tables_keep_april30_transactional_sampling( + system, name, year, thresholds, rates +): + scale = system.parameters.get_child(f"{PATH}.{name}")(f"{year}-01-01") + assert scale.thresholds == thresholds + assert scale.rates == rates From 83de1ef1d604b98324c94681263821ec4d67e317 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 20:19:57 +0100 Subject: [PATCH 066/224] Correct historical dividend income tax band thresholds --- changelog.d/1822.fixed.md | 1 + .../parameters/gov/hmrc/income_tax/README.md | 43 ++++++++ .../gov/hmrc/income_tax/rates/dividends.yaml | 36 +++++++ .../hmrc/income_tax/dividend_band_dates.yaml | 101 ++++++++++++++++++ .../tests/test_dividend_band_dates.py | 73 +++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 changelog.d/1822.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml create mode 100644 policyengine_uk/tests/test_dividend_band_dates.py diff --git a/changelog.d/1822.fixed.md b/changelog.d/1822.fixed.md new file mode 100644 index 0000000000..4a057f7079 --- /dev/null +++ b/changelog.d/1822.fixed.md @@ -0,0 +1 @@ +- Corrected dividend income-tax band thresholds from 2016 to 2025 to use their statutory tax-year dates. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/README.md index 3a6da4412d..c21b449d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/README.md @@ -1 +1,44 @@ # Income Tax + +## Dividend bands + +[Income Tax Act 2007 s. 13](https://www.legislation.gov.uk/ukpga/2007/3/section/13) +ties the dividend rates to the corresponding UK taxable-income bands, including +when determining the dividend bands for a Scottish taxpayer. +[Section 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16) places +dividends above other income. The dividend nil-rate amount uses band capacity; +it does not reduce income when determining the remaining capacity in each band. + +The dated dividend scale records these basic-rate limits: + +| Tax year beginning 6 April | Basic-rate limit | Authority | +| --- | ---: | --- | +| 2016 | £32,000 | [Finance (No. 2) Act 2015 s. 6](https://www.legislation.gov.uk/ukpga/2015/33/section/6/enacted) | +| 2017 | £33,500 | [Finance Act 2016 s. 2](https://www.legislation.gov.uk/ukpga/2016/24/section/2/enacted) | +| 2018 | £34,500 | [SI 2017/1184 art 2](https://www.legislation.gov.uk/uksi/2017/1184/article/2/made) | +| 2019 and 2020 | £37,500 | [Finance Act 2019 s. 5](https://www.legislation.gov.uk/ukpga/2019/1/section/5/enacted) | +| From 2021 | £37,700 | [SI 2021/111 art 2](https://www.legislation.gov.uk/uksi/2021/111/article/2/made) | + +The additional-rate threshold falls from £150,000 to £125,140 from 6 April 2023 +under [Finance Act 2023 s. 6](https://www.legislation.gov.uk/ukpga/2023/1/section/6/enacted). +These limits apply after allowances. For example, in 2025, £49,000 of pay uses +£36,430 of taxable band capacity after the £12,570 Personal Allowance. Of a +further £2,000 of dividends, £1,270 fits in the basic band, including the £500 +dividend nil-rate amount. The charge is £770 × 8.75% + £730 × 33.75% = £313.75. + +The threshold history does not certify the pre-2016 dividend-tax-credit regime +or all historical allowance calculations. The inherited 2016 Personal Allowance +is a separate issue tracked in [#2359](https://github.com/PolicyEngine/policyengine-uk/issues/2359): +the inherited amount was £10,600 while the statutory amount is £11,000. The 2016 regression +uses £160,000 of dividends, which naturally withdraws the full allowance under +either amount. It exercises the dividend bands without selecting an allowance +through an intermediate input. Dividend rates, allowance allocation, Gift Aid +extensions and the optional extra dividend bracket retain their existing +interfaces. + +The existing fixed-order allowance allocation does not always implement the +most-beneficial allocation required by ITA 2007 s. 25(2) for mixed wages and +dividends. The 2017, 2021 and 2026 threshold regressions also use dividend-only +income with a naturally zero Personal Allowance. The selected 2018, 2019 and +2025 mixed-income cases have independently checked allowance allocations; +they do not certify the allocation formula for other situations. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml index 74fba94512..3cb13cd423 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml @@ -55,6 +55,36 @@ brackets: label: Dividends higher rate threshold values: 2015-04-01: 37_500 + 2016-04-06: + value: 32_000 + metadata: + reference: + - title: Finance (No. 2) Act 2015 s. 6(a) + href: https://www.legislation.gov.uk/ukpga/2015/33/section/6/enacted + 2017-04-06: + value: 33_500 + metadata: + reference: + - title: Finance Act 2016 s. 2 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/2/enacted + 2018-04-06: + value: 34_500 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2017 art 2 + href: https://www.legislation.gov.uk/uksi/2017/1184/article/2/made + 2019-04-06: + value: 37_500 + metadata: + reference: + - title: Finance Act 2019 s. 5(1) + href: https://www.legislation.gov.uk/ukpga/2019/1/section/5/enacted + 2021-04-06: + value: 37_700 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2021 art 2 + href: https://www.legislation.gov.uk/uksi/2021/111/article/2/made 2026-04-06: value: 37_700 metadata: @@ -79,6 +109,12 @@ brackets: threshold: values: 2015-04-01: 150_000 + 2023-04-06: + value: 125_140 + metadata: + reference: + - title: Finance Act 2023 s. 6(3)-(4) + href: https://www.legislation.gov.uk/ukpga/2023/1/section/6/enacted 2026-04-06: value: 125_140 metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml new file mode 100644 index 0000000000..519e44f94e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml @@ -0,0 +1,101 @@ +# Literal official-source band arithmetic; the dividend nil rate occupies band capacity. +# PA0 cases isolate2016/2017/2021/2026 bands from separate allowance limitations. +- name: 2016 dividends with wages 0 and dividends 160000 + period: 2016 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 44185.0 + income_tax: 44185.0 +- name: 2017 dividends with wages 0 and dividends 160000 + period: 2017 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 43810.0 + income_tax: 43810.0 +- name: 2018 dividends with wages 40000 and dividends 10000 + period: 2018 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 40000 + dividend_income: 10000 + output: + dividend_income_tax: 1512.5 + income_tax: 7142.5 +- name: 2019 dividends with wages 40000 and dividends 10000 + period: 2019 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 40000 + dividend_income: 10000 + output: + dividend_income_tax: 600.0 + income_tax: 6100.0 +- name: 2021 dividends with wages 0 and dividends 160000 + period: 2021 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 42985.0 + income_tax: 42985.0 +- name: 2023 dividends with wages 130000 and dividends 10000 + period: 2023 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 130000 + dividend_income: 10000 + output: + dividend_income_tax: 3541.5 + income_tax: 48244.5 +- name: 2025 dividends with wages 49000 and dividends 2000 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 49000 + dividend_income: 2000 + output: + dividend_income_tax: 313.75 + income_tax: 7599.75 +- name: 2025 dividends with wages 130000 and dividends 10000 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 130000 + dividend_income: 10000 + output: + dividend_income_tax: 3738.25 + income_tax: 48441.25 +- name: 2026 dividends with wages 0 and dividends 160000 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 48976.21 + income_tax: 48976.21 diff --git a/policyengine_uk/tests/test_dividend_band_dates.py b/policyengine_uk/tests/test_dividend_band_dates.py new file mode 100644 index 0000000000..969b759580 --- /dev/null +++ b/policyengine_uk/tests/test_dividend_band_dates.py @@ -0,0 +1,73 @@ +"""Dated dividend limits and preserved rate/reform interfaces. + +ITA 2007 s.13 uses the corresponding UK taxable-income bands. Earlier +unchanged values below are regression controls, not a pre-2016 law audit. +""" + +from pathlib import Path + +import pytest +import yaml +from policyengine_core.parameters import Parameter + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + +PATH = "gov.hmrc.income_tax.rates.dividends" +FILE = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/income_tax/rates/dividends.yaml" +) + + +@pytest.mark.parametrize( + "bracket,before,after,old,new", + [ + (1, "2016-04-05", "2016-04-06", 37_500, 32_000), + (1, "2017-04-05", "2017-04-06", 32_000, 33_500), + (1, "2018-04-05", "2018-04-06", 33_500, 34_500), + (1, "2019-04-05", "2019-04-06", 34_500, 37_500), + (1, "2021-04-05", "2021-04-06", 37_500, 37_700), + (2, "2023-04-05", "2023-04-06", 150_000, 125_140), + ], +) +def test_raw_dividend_limits_change_on_the_statutory_tax_year_date( + bracket, before, after, old, new +): + data = yaml.safe_load(FILE.read_text())["brackets"][bracket]["threshold"] + data["values"] = {str(date): value for date, value in data["values"].items()} + raw = Parameter(f"{PATH}[{bracket}].threshold", data=data, file_path=str(FILE)) + assert raw(before) == old + assert raw(after) == new + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "year,basic_limit,additional_limit", + [ + (2016, 32_000, 150_000), + (2017, 33_500, 150_000), + (2018, 34_500, 150_000), + (2019, 37_500, 150_000), + (2020, 37_500, 150_000), + (2021, 37_700, 150_000), + (2023, 37_700, 125_140), + (2025, 37_700, 125_140), + (2026, 37_700, 125_140), + ], +) +def test_annual_dividend_limits_include_restoration_and_current_years( + system, year, basic_limit, additional_limit +): + scale = system.parameters.get_child(PATH)(f"{year}-01-01") + assert scale.thresholds == [0, basic_limit, additional_limit, 10_000_000] + + +def test_2026_dividend_rates_and_optional_extra_bracket_are_unchanged(system): + scale = system.parameters.get_child(PATH)("2026-01-01") + assert scale.rates == [0.1075, 0.3575, 0.3935, 0.3935] + assert scale.thresholds == [0, 37_700, 125_140, 10_000_000] From 287cd8b011200c247fa2bc5aaeb4ecafd8aea074 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 21:05:57 +0100 Subject: [PATCH 067/224] Fix Savings Credit eligibility gate --- changelog.d/2121.fixed.md | 1 + .../savings_credit/savings_credit.yaml | 2 + .../tests/test_savings_credit_eligibility.py | 152 ++++++++++++++++++ .../savings_credit/savings_credit.py | 7 + 4 files changed, 162 insertions(+) create mode 100644 changelog.d/2121.fixed.md create mode 100644 policyengine_uk/tests/test_savings_credit_eligibility.py diff --git a/changelog.d/2121.fixed.md b/changelog.d/2121.fixed.md new file mode 100644 index 0000000000..5916ccce06 --- /dev/null +++ b/changelog.d/2121.fixed.md @@ -0,0 +1 @@ +- Applied the shared Pension Credit eligibility conditions to computed Savings Credit amounts. 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 1d613f2042..f43589f7f5 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 @@ -2,6 +2,7 @@ period: 2026 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 20_000 @@ -15,6 +16,7 @@ period: 2026 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 20_000 diff --git a/policyengine_uk/tests/test_savings_credit_eligibility.py b/policyengine_uk/tests/test_savings_credit_eligibility.py new file mode 100644 index 0000000000..04a2275d1c --- /dev/null +++ b/policyengine_uk/tests/test_savings_credit_eligibility.py @@ -0,0 +1,152 @@ +"""Savings Credit shares Pension Credit eligibility, not just its local age test. + +The literal amounts use the existing 2026 model rates. The mixed-age case's +old positive amount is an off-route diagnostic, not lawful entitlement. The +positive controls reached pensionable age before April 2016; no protected +mixed-age Savings Credit continuity is inferred from reported Pension Credit. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.variables.gov.dwp.pension_credit.savings_credit.savings_credit import ( + savings_credit, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2026 + + +def situation(): + units = [ + ([(80, 20_000, 0), (60, 0, 0)], 6_240), + ([(80, 9_000, 0), (78, 9_000, 0)], 6_240), + ([(80, 11_000, 2_000)], 7_800), + ([(40, 0, 0)], 6_240), + ] + people, benunits, households = {}, {}, {} + for i, (adults, rent) in enumerate(units): + names = [] + for j, (age, pension, private_pension) in enumerate(adults): + name = f"p{i}_{j}" + names.append(name) + people[name] = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: True}, + "state_pension": {YEAR: pension}, + "private_pension_income": {YEAR: private_pension}, + } + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {YEAR: True}, + "would_claim_housing_benefit": {YEAR: True}, + "would_claim_uc": {YEAR: False}, + } + households[f"h{i}"] = { + "members": names, + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: rent}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: 0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def ungated_simulation(): + """Retain the former amount formula for a downstream differential control.""" + sim = Simulation(situation=situation()) + sim.tax_benefit_system.update_variable( + type("savings_credit", (savings_credit,), {"defined_for": None}) + ) + return sim + + +@pytest.mark.parametrize("first", ["savings_credit", "pension_credit"]) +def test_unprotected_mixed_age_savings_credit_is_zero(first): + sim = Simulation(situation=situation()) + sim.calculate(first, YEAR) + # The older member meets the local pre-2016 age condition, but the + # unprotected younger partner excludes the couple from Pension Credit. + assert sim.calculate("is_savings_credit_eligible", YEAR).tolist() == [ + True, + True, + True, + False, + ] + assert sim.calculate("is_pension_credit_eligible", YEAR).tolist() == [ + False, + True, + True, + False, + ] + # Couple: 0.6 * (18,000 - 329.75*52) = 511.80. + # Single: 0.6*(238-208.07)*52 - 0.4*(12,914-238*52) = 718.616. + np.testing.assert_allclose( + sim.calculate("savings_credit", YEAR), + [0, 511.80, 718.616, 0], + atol=0.01, + rtol=0, + ) + + +def test_shared_gate_preserves_final_pension_credit_and_consumers(): + fixed = Simulation(situation=situation()) + former = ungated_simulation() + # The old off-route calculation was 0.6*(18,766-329.75*52) = 971.40, with the default married + # couple receiving the existing Marriage Allowance of 1,260. + assert former.calculate("savings_credit", YEAR)[0] == pytest.approx( + 971.40, abs=0.01 + ) + assert fixed.calculate("savings_credit", YEAR)[0] == 0 + consumers = [ + "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", + ] + for variable in consumers: + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + former.calculate(variable, YEAR), + atol=0.01, + rtol=0, + err_msg=variable, + ) + # Both-old couple: Guarantee Credit 18,889-18,000 = 889, plus 511.80. + for variable in ("pension_credit_entitlement", "pension_credit"): + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + [0, 1_400.80, 718.616, 0], + atol=0.01, + rtol=0, + ) + # The couple gets full rent on Guarantee Credit. The single's HB is + # 7,800 - 0.65*(12,914+718.616-256*52) = 7,591.5996. + np.testing.assert_allclose( + fixed.calculate("housing_benefit", YEAR), + [0, 6_240, 7_591.5996, 0], + atol=0.01, + rtol=0, + ) + + +def test_direct_savings_credit_input_keeps_its_existing_override_behavior(): + data = situation() + data["benunits"]["b0"]["savings_credit"] = {YEAR: 123} + sim = Simulation(situation=data) + assert sim.calculate("savings_credit", YEAR)[0] == 123 + # An explicitly supplied component does not create a Pension Credit award. + 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 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 65a2474b9f..62b6eb3e2c 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 @@ -8,6 +8,13 @@ class savings_credit(Variable): value_type = float unit = GBP reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + 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." + ) + defined_for = "is_pension_credit_eligible" def formula(benunit, period, parameters): income = benunit("savings_credit_income", period) From 1520ce94d84e5d7e16f6608f99046b6ee61cfab3 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 21:49:53 +0100 Subject: [PATCH 068/224] Restore historical Care to Learn weekly caps --- changelog.d/1932.fixed.md | 1 + .../gov/dfe/care_to_learn/README.md | 29 ++ .../gov/dfe/care_to_learn/amount.yaml | 20 ++ .../care_to_learn/care_to_learn_history.yaml | 256 ++++++++++++++++++ .../tests/test_care_to_learn_history.py | 75 +++++ 5 files changed, 381 insertions(+) create mode 100644 changelog.d/1932.fixed.md create mode 100644 policyengine_uk/parameters/gov/dfe/care_to_learn/README.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml create mode 100644 policyengine_uk/tests/test_care_to_learn_history.py diff --git a/changelog.d/1932.fixed.md b/changelog.d/1932.fixed.md new file mode 100644 index 0000000000..b6f715822a --- /dev/null +++ b/changelog.d/1932.fixed.md @@ -0,0 +1 @@ +- Corrected historical Care to Learn weekly caps while preserving the existing annual date approximation. diff --git a/policyengine_uk/parameters/gov/dfe/care_to_learn/README.md b/policyengine_uk/parameters/gov/dfe/care_to_learn/README.md new file mode 100644 index 0000000000..4ef060ab67 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfe/care_to_learn/README.md @@ -0,0 +1,29 @@ +# Care to Learn amount history + +The weekly maximum per child was £160 outside London and £175 for a young +parent whose home address was in London in the official [2014–15 EFA guide, +paragraph 3](https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4), +[2015–16 guide](https://www.gov.uk/guidance/care-to-learn-guide-for-institutions) +and [2022–23 ESFA guide](https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year). +The [2023–24 guide, section 1](https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year) +explicitly increases those amounts to £180 and £195 for that academic year. +The [2026–27 conditions, sections 2.2 and 4.1](https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding) +retain the newer amounts. Intervening annual editions were not individually +verified, and this history does not establish pre-2014 rates. + +The parameter dates `2015-01-01` and `2024-01-01` are annual model anchors. +They are not certified daily academic-year commencement dates or fiscal-year +averages. The model retains its existing 30 April parameter sample: annual +model years 2015–2023 use £160/£175, and 2024 onward uses £180/£195. The guides' +publication dates, examples of course start dates and summer-retainer deadlines +do not establish a universal day on which the rate changed. No academic-year +allocation or fiscal-year blending is introduced. + +The amount formula still caps each qualifying child's annual childcare costs +at 52 times the weekly maximum and pays the benefit-unit total once. That is an +annual modelling approximation: the actual scheme funds eligible childcare +and travel based on attendance and costs. Travel, course dates and funded +weeks are not modelled. Existing eligibility proxies, including current age +rather than age when the course started, remain unchanged; this rate history +does not certify all course-funding, other-parent, immigration or other +eligibility conditions. diff --git a/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml b/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml index 7fd191166c..8a416223ec 100644 --- a/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml +++ b/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml @@ -6,6 +6,14 @@ in_london: period: week label: Maximum weekly amount per child in London reference: + - title: EFA, Care to Learn guide 2014 to 2015, paragraph 3 (physical page 4) + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4 + - title: ESFA, Care to Learn guide for institutions, 2015 to 2016 + href: https://www.gov.uk/guidance/care-to-learn-guide-for-institutions + - title: ESFA, Care to Learn guide 2022 to 2023, section 1 + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year + - title: ESFA, Care to Learn guide 2023 to 2024, section 1 (rate increase) + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year - title: ESFA, Care to Learn 2026 to 2027 academic year conditions of grant funding, sections 2.2 and 4.1 href: https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding - title: Documentation for Care to Learn scheme @@ -13,6 +21,8 @@ in_london: - title: House of Commons Library – Paying for Childcare in England (12 July 2024) href: https://researchbriefings.files.parliament.uk/documents/CBP-8054/CBP-8054.pdf#page=43 values: + # Annual model anchors; see README.md for the academic-year approximation. + 2015-01-01: 175 2024-01-01: 195 outside_london: @@ -22,6 +32,14 @@ outside_london: period: week label: Maximum weekly amount per child outside London reference: + - title: EFA, Care to Learn guide 2014 to 2015, paragraph 3 (physical page 4) + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4 + - title: ESFA, Care to Learn guide for institutions, 2015 to 2016 + href: https://www.gov.uk/guidance/care-to-learn-guide-for-institutions + - title: ESFA, Care to Learn guide 2022 to 2023, section 1 + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year + - title: ESFA, Care to Learn guide 2023 to 2024, section 1 (rate increase) + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year - title: ESFA, Care to Learn 2026 to 2027 academic year conditions of grant funding, sections 2.2 and 4.1 href: https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding - title: Documentation for Care to Learn scheme @@ -29,4 +47,6 @@ outside_london: - title: House of Commons Library – Paying for Childcare in England (12 July 2024) href: https://researchbriefings.files.parliament.uk/documents/CBP-8054/CBP-8054.pdf#page=43 values: + # Annual model anchors; see README.md for the academic-year approximation. + 2015-01-01: 160 2024-01-01: 180 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml new file mode 100644 index 0000000000..84d66af97d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml @@ -0,0 +1,256 @@ +# These are annual model-year anchors, not exact academic-year start dates. +# The inherited annual formula caps costs at 52 times the weekly maximum. +# Old rates: EFA 2014-15 p4 and ESFA 2022-23; new rates: ESFA 2023-24 s1. +- name: 2015 Care to Learn annual history EAST_OF_ENGLAND + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [8_320, 0] + +- name: 2015 Care to Learn annual history LONDON + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [9_100, 0] + +- name: 2023 Care to Learn annual history EAST_OF_ENGLAND + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [8_320, 0] + +- name: 2023 Care to Learn annual history LONDON + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [9_100, 0] + +- name: 2024 Care to Learn annual history EAST_OF_ENGLAND + period: 2024 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [9_360, 0] + +- name: 2024 Care to Learn annual history LONDON + period: 2024 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [10_140, 0] + +- name: 2026 Care to Learn annual history EAST_OF_ENGLAND + period: 2026 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [9_360, 0] + +- name: 2026 Care to Learn annual history LONDON + period: 2026 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [10_140, 0] + +- name: 2015 Care to Learn annual history LOW_COST + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 3_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [3_000, 0] + +- name: 2023 Care to Learn annual history SCOTLAND + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: SCOTLAND + region: SCOTLAND + output: + care_to_learn: [0, 0] + +- name: 2015 Care to Learn annual history NOT_IN_EDUCATION + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: NOT_IN_EDUCATION + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [0, 0] + diff --git a/policyengine_uk/tests/test_care_to_learn_history.py b/policyengine_uk/tests/test_care_to_learn_history.py new file mode 100644 index 0000000000..e892a52e3d --- /dev/null +++ b/policyengine_uk/tests/test_care_to_learn_history.py @@ -0,0 +1,75 @@ +"""Annual-model history and rate overrides, not an academic-day allocation.""" + +from pathlib import Path + +import pytest +from policyengine_core.parameters import ParameterNode + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def test_raw_amount_keeps_the_annual_model_history_anchors(): + directory = Path(__file__).parents[1] / "parameters/gov/dfe/care_to_learn" + raw = ParameterNode(directory_path=str(directory)).amount + # These are deliberately annual model anchors, not legal daily switches. + for date in ("2015-01-01", "2023-12-31"): + assert raw(date).in_london == 175 + assert raw(date).outside_london == 160 + for date in ("2024-01-01", "2026-04-30"): + assert raw(date).in_london == 195 + assert raw(date).outside_london == 180 + + +def test_processed_amount_keeps_old_and_current_annual_caps(): + parameters = CountryTaxBenefitSystem().parameters + for year, london, elsewhere in ( + (2015, 175, 160), + (2023, 175, 160), + (2024, 195, 180), + (2026, 195, 180), + ): + amount = parameters(str(year)).gov.dfe.care_to_learn.amount + assert amount.in_london == london + assert amount.outside_london == elsewhere + + +def test_historical_rate_override_reaches_final_household_benefits(): + situation = { + "people": { + "parent": { + "age": {2015: 18}, + "is_parent": {2015: True}, + "current_education": {2015: "UPPER_SECONDARY"}, + "is_apprentice": {2015: False}, + }, + "child": {"age": {2015: 1}, "childcare_expenses": {2015: 12_000}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": { + "home": { + "members": ["parent", "child"], + "country": {2015: "ENGLAND"}, + "region": {2015: "EAST_OF_ENGLAND"}, + } + }, + } + baseline = Simulation(situation=situation) + reformed = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dfe.care_to_learn.amount.outside_london": {2015: 200} + } + ), + ) + # Model-contract arithmetic: 160 * 52 = 8320, 200 * 52 = 10400. + assert baseline.calculate("care_to_learn", 2015).tolist() == [8_320, 0] + assert reformed.calculate("care_to_learn", 2015).tolist() == [10_400, 0] + difference = reformed.calculate("household_benefits", 2015) - baseline.calculate( + "household_benefits", 2015 + ) + assert difference[0] == pytest.approx(2_080, abs=0.01) 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 069/224] 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 f17f06023306d63e97652ac82774591b653a64e7 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 02:07:29 +0100 Subject: [PATCH 070/224] Add UC housing contribution exemption for adults responsible for under-fives --- changelog.d/2071.fixed.md | 1 + .../child_responsibility_age_limit.yaml | 14 + ...d_responsibility_housing_contribution.yaml | 674 ++++++++++++++++++ .../uc_non_dep_deduction_exempt.py | 17 +- 4 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2071.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml create mode 100644 policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml diff --git a/changelog.d/2071.fixed.md b/changelog.d/2071.fixed.md new file mode 100644 index 0000000000..84ff5437b4 --- /dev/null +++ b/changelog.d/2071.fixed.md @@ -0,0 +1 @@ +- Exempted non-dependants responsible for a child under five from Universal Credit housing cost contributions. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml new file mode 100644 index 0000000000..a59a386f11 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml @@ -0,0 +1,14 @@ +description: A non-dependant responsible for a child below this age is exempt from Universal Credit housing cost contributions. +values: + 2013-04-29: 5 +metadata: + unit: year + period: year + label: Universal Credit housing contribution child responsibility age limit + reference: + - title: Universal Credit Regulations 2013, Schedule 4 paragraph 16(2)(i) + href: https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16 + - title: Universal Credit Regulations 2013, regulation 4 + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/4 + - title: Universal Credit Regulations 2013, regulation 1 (commencement on 29 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/1/made diff --git a/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml b/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml new file mode 100644 index 0000000000..db7f5b2901 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml @@ -0,0 +1,674 @@ +# UC Regs 2013 Sch 4 para 16(2)(i), reg 4: own-family responsibility, strict under-5 age. +# Source-derived 2026 annual contribution: 12 x 96.55 = 1,158.60. +# Unchanged single-over-25 standard allowance: 12 x 424.90 = 5,098.80. +# With no earnings/capital/debt and nonbinding cap, renter UC = standard + council rent - contributions. +# The non-dependant family does not claim UC; legal roles and membership, rather than forced awards/ages/exemptions, establish responsibility. + +- name: UC housing contribution - non-claiming parent of a four-year-old is exempt + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - exact fifth birthday ends the parent exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 5 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - childless non-dependant retains the deduction + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - looked-after child does not confer responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - both partners responsible for their young child are exempt + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + partner: + age: 28 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - partner + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - partner + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - an unrelated family does not inherit parent exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + outsider: + age: 35 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + outsider_family: + members: + - outsider + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + - outsider + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + - 0 + universal_credit: + - 9940.2 + - 0 + - 0 +- name: UC housing contribution - non-claimant adult in the family does not inherit responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + outsider: + age: 35 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + - outsider + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + - outsider + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - reversed people and family order with final award requested + first + period: 2026 + absolute_error_margin: 0.02 + input: + people: + child: + age: 4 + is_claimant_or_partner: false + parent: + age: 30 + is_claimant_or_partner: true + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + benunits: + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + households: + household: + members: + - child + - parent + - renter + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + universal_credit: + - 0 + - 11098.8 + uc_housing_costs_element: + - 0 + - 6000 + uc_non_dep_deductions: + - 0 + - 0 + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deduction_exempt: + - false + - true + - false +- name: UC housing contribution - existing under-21 exception remains without parent responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 20 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - responsibility starts with a newborn + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 0 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 + +# Configuration controls are appended after the cited parameter exists. +- name: UC housing contribution - lowering responsibility age limit removes the exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + gov.dwp.universal_credit.elements.housing.non_dep_deduction.child_responsibility_age_limit: 4 + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - raising responsibility age limit exempts a five-year-old + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 5 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + gov.dwp.universal_credit.elements.housing.non_dep_deduction.child_responsibility_age_limit: 6 + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py index 6c81d42cad..be35b6197f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py @@ -10,17 +10,32 @@ class uc_non_dep_deduction_exempt(Variable): "No housing costs contribution is deducted for a non-dependant who is " "receiving Pension Credit, the middle or higher rate of the DLA care " "component, the PIP daily living component or Attendance Allowance, or " - "who receives Carer's Allowance or Carer Support Payment. Underlying " + "who receives Carer's Allowance or Carer Support Payment, or who is " + "responsible for a child under the child responsibility age limit. " + "Benefit-unit membership represents the child's normal residence " + "with the claimant or partner; the existing Universal Credit child " + "age measure excludes children looked after by a local authority. Underlying " "entitlement reduced to nil by overlapping benefits does not qualify " "for the carer exemption." ) reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16" def formula(person, period, parameters): + p = parameters( + period + ).gov.dwp.universal_credit.elements.housing.non_dep_deduction + # Sch 4 para 16(2)(i) and reg 4: responsibility belongs to the + # claimant or partner of the child's own family, independently of + # whether that family claims Universal Credit. + responsible_for_young_child = person("is_claimant_or_partner", period) & ( + person.benunit("uc_youngest_child_age", period) + < p.child_responsibility_age_limit + ) return ( person.benunit("uc_non_dep_receives_pension_credit", period) | person("dla_sc_middle_plus", period) | (person("pip_dl", period) > 0) | (person("attendance_allowance", period) > 0) | person("receives_carer_benefit", period) + | responsible_for_young_child ) From 5092abf8ebb917f4507fd2ce0f57c64493892e63 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 03:25:09 +0100 Subject: [PATCH 071/224] Fix UC higher disabled-child eligibility --- changelog.d/1970.fixed.md | 1 + .../benefit_amount_tolerance.yaml | 10 + ...idual_severely_disabled_child_element.yaml | 4 +- .../claimant_child_element.yaml | 2 +- .../uc_disabled_child_addition.yaml | 202 ++++++++++++++++++ ...t_uc_disabled_child_addition_properties.py | 164 ++++++++++++++ ...eets_uc_higher_disabled_child_condition.py | 41 ++++ ...ividual_severely_disabled_child_element.py | 2 +- .../uc_individual_disabled_child_element.py | 3 +- .../is_severely_disabled_for_benefits.py | 8 +- 10 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 changelog.d/1970.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml create mode 100644 policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml create mode 100644 policyengine_uk/tests/test_uc_disabled_child_addition_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py diff --git a/changelog.d/1970.fixed.md b/changelog.d/1970.fixed.md new file mode 100644 index 0000000000..eadc51f308 --- /dev/null +++ b/changelog.d/1970.fixed.md @@ -0,0 +1 @@ +- Separated Universal Credit's higher disabled child condition from tax credits, included certified blindness, and prevented the lower and higher additions from stacking. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml new file mode 100644 index 0000000000..8821f5f5e4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml @@ -0,0 +1,10 @@ +description: Weekly tolerance for the modeled paid-amount proxy for qualifying DLA care and PIP daily living awards in the Universal Credit higher disabled child condition. This is an existing input approximation, not a statutory entitlement threshold. +values: + 2015-01-01: 10 +metadata: + unit: currency-GBP + period: week + label: UC higher disabled child benefit amount proxy tolerance + reference: + - title: Retained PolicyEngine-UK DLA and PIP paid-amount input convention + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml index bd55e902ca..dac7d76bc1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml @@ -4,7 +4,7 @@ input: age: 6 is_uc_claimant: false - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER output: uc_individual_severely_disabled_child_element: 5_942 @@ -14,7 +14,7 @@ input: age: 18 is_in_non_advanced_education: true - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER output: is_uc_claimant: true uc_individual_severely_disabled_child_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml index 6ed4fd7ae8..d41cde1a66 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml @@ -115,7 +115,7 @@ is_disabled_for_benefits: true person5: age: 2 - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER benunits: benunit: members: [person1, person2, person3, person4, person5] diff --git a/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml b/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml new file mode 100644 index 0000000000..c1318369e7 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml @@ -0,0 +1,202 @@ +# Classification: UC Regs 2013 reg 24(2), with the existing statutory-blind +# input. Cash anchors are the unchanged model's 2026 monthly amounts: +# standard 424.90, ordinary child 303.94, lower 164.79, higher 514.71. +# A parent with a post-April-2017 child and no recovered debt therefore gets +# 12 * (424.90 + 303.94 + the single applicable disability addition). +# Only blind/no-award cases assert final UC: the existing generic disability +# flag also defaults LCWRA for child members, a separate model approximation. + +- name: A certified blind dependent receives the higher UC child addition without a disability benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_blind: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + uc_disability_elements: 6_176.52 + universal_credit: 14_922.60 + +- name: Blindness and highest DLA care give one higher addition rather than both tiers + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_blind: true + dla_sc_category: HIGHER + is_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + +- name: Middle DLA care retains only the lower disabled child addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + dla_sc_category: MIDDLE + is_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 1_977.48] + uc_individual_severely_disabled_child_element: [0, 0] + +- name: A nonblind child without a qualifying award receives no disability addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 0] + universal_credit: 8_746.08 + +- name: An enhanced PIP daily living qualifying young person receives only the higher addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_benunit_head: true + young_person: + age: 18 + is_benunit_head: false + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 17 + pip_dl_category: ENHANCED + is_disabled_for_benefits: true + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + output: + is_uc_claimant: [true, false] + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + +- name: A blind young claimant does not receive a child addition for themselves + period: 2026 + absolute_error_margin: 0.01 + input: + age: 18 + is_in_non_advanced_education: true + is_blind: true + is_disabled_for_benefits: true + output: + is_uc_claimant: true + uc_individual_disabled_child_element: 0 + uc_individual_severely_disabled_child_element: 0 + +- name: A blind outside adult does not create a child addition for another family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + outside_adult: + age: 30 + is_blind: true + benunits: + family: + members: [parent, child] + other_family: + members: [outside_adult] + households: + home: + members: [parent, child, outside_adult] + output: + uc_individual_disabled_child_element: [0, 0, 0] + uc_individual_severely_disabled_child_element: [0, 0, 0] + +- name: A stored tax credit severe flag alone is not evidence of the UC higher condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_severely_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + households: + home: + members: [parent, child] + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 0] diff --git a/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py b/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py new file mode 100644 index 0000000000..86c654f768 --- /dev/null +++ b/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py @@ -0,0 +1,164 @@ +"""UC reg 24(2): one disabled-child tier, with an independent blind limb. + +The exact award categories provide the entitlement classification oracle. +The amount-only checks separately preserve the existing, nonstatutory paid- +amount proxy; they do not establish entitlement from part-year payments. +Generated reported categories test the conditional input contract, not the +underlying age or overlapping-award eligibility of each generated combination. +2026 cash anchors are the unchanged monthly amounts 164.79 and 514.71. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import WEEKS_IN_YEAR + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2026 +LOWER = 1_977.48 +HIGHER = 6_176.52 + + +def family(child, reverse=False): + people = { + "parent": { + "age": {YEAR: 35}, + "is_parent": {YEAR: True}, + "is_benunit_head": {YEAR: True}, + }, + "child": { + "age": {YEAR: 8}, + "is_benunit_head": {YEAR: False}, + **{name: {YEAR: value} for name, value in child.items()}, + }, + } + names = list(people) + if reverse: + people = dict(reversed(list(people.items()))) + names.reverse() + return { + "people": people, + "benunits": {"family": {"members": names}}, + "households": {"home": {"members": names}}, + } + + +def child_additions(sim): + ids = list(sim.populations["person"].ids) + child = ids.index("child") + return ( + sim.calculate("uc_individual_disabled_child_element", YEAR)[child], + sim.calculate("uc_individual_severely_disabled_child_element", YEAR)[child], + ) + + +@settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + age=st.integers(0, 15), + dla=st.sampled_from(["NONE", "LOWER", "MIDDLE", "HIGHER"]), + pip=st.sampled_from(["NONE", "STANDARD", "ENHANCED"]), + blind=st.booleans(), + lower_flag=st.booleans(), + reverse=st.booleans(), +) +def test_exact_award_categories_and_blindness_select_one_tier( + age, dla, pip, blind, lower_flag, reverse +): + sim = Simulation( + situation=family( + { + "age": age, + "dla_sc_category": dla, + "pip_dl_category": pip, + "is_blind": blind, + "is_disabled_for_benefits": lower_flag, + }, + reverse=reverse, + ) + ) + higher = blind or dla == "HIGHER" or pip == "ENHANCED" + expected = (LOWER if lower_flag and not higher else 0, HIGHER if higher else 0) + actual = child_additions(sim) + np.testing.assert_allclose(actual, expected, atol=0.01, rtol=0) + assert not (actual[0] > 0 and actual[1] > 0) + # Claimant roles remain excluded, even when entity/member order changes. + parent = list(sim.populations["person"].ids).index("parent") + for variable in ( + "uc_individual_disabled_child_element", + "uc_individual_severely_disabled_child_element", + ): + assert sim.calculate(variable, YEAR)[parent] == 0 + + +@pytest.mark.parametrize( + "awards, higher", + [ + ({"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR - 1}, False), + ({"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR}, True), + ({"pip_dl": (114.6 - 10) * WEEKS_IN_YEAR + 1}, True), + ({"dla_sc_category": "HIGHER", "dla_sc": 0}, False), + ({"pip_dl_category": "ENHANCED", "pip_dl": 0}, False), + ], + ids=[ + "DLA-below-proxy", + "DLA-at-proxy", + "PIP-above-proxy", + "DLA-direct-zero", + "PIP-direct-zero", + ], +) +def test_existing_direct_amount_proxy_and_override_precedence(awards, higher): + # Weekly 114.60 is the existing 2026 high/enhanced rate; 10 is the + # retained modeling tolerance, not a statutory entitlement threshold. + sim = Simulation(situation=family({**awards, "is_disabled_for_benefits": True})) + np.testing.assert_allclose( + child_additions(sim), + (0, HIGHER) if higher else (LOWER, 0), + atol=0.01, + rtol=0, + ) + + +def test_zero_higher_rate_reform_does_not_pay_the_lower_tier_instead(): + reform = Reform.from_dict( + { + "gov.dwp.universal_credit.elements.child.severely_disabled.amount": { + "2026-01-01.2026-12-31": 0 + } + }, + country_id="uk", + ) + sim = Simulation( + situation=family( + {"dla_sc_category": "HIGHER", "is_disabled_for_benefits": True} + ), + reform=reform, + ) + np.testing.assert_array_equal(child_additions(sim), [0, 0]) + + +def test_amount_proxy_tolerance_is_configurable_without_changing_award_rates(): + reform = Reform.from_dict( + { + "gov.dwp.universal_credit.elements.child.severely_disabled.benefit_amount_tolerance": { + "2026-01-01.2026-12-31": 0 + } + }, + country_id="uk", + ) + sim = Simulation( + situation=family( + {"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR, "is_disabled_for_benefits": True} + ), + reform=reform, + ) + np.testing.assert_allclose(child_additions(sim), [LOWER, 0], atol=0.01, rtol=0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py new file mode 100644 index 0000000000..79b05331ab --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class meets_uc_higher_disabled_child_condition(Variable): + value_type = bool + entity = Person + label = "Meets the Universal Credit higher disabled child condition" + definition_period = YEAR + documentation = ( + "The higher UC disabled child condition has an independent certified " + "blindness limb and qualifying DLA care or PIP daily living awards. " + "The model reads DLA and PIP amounts, computed from their categories " + "unless an amount is supplied directly. It retains the existing " + "nonstatutory weekly amount tolerance for those paid-amount proxies; " + "they do not establish underlying entitlement from partial payments. " + "Separate Scottish awards and benefit payable but for a hospital " + "stay are not represented. The child amount separately checks " + "dependent child or qualifying young person status and excludes " + "the UC claimant. The shared tax-credit severe disability flag, " + "attendance allowance and AFIP do not establish this UC condition." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/24", + "https://www.legislation.gov.uk/uksi/2016/216/regulation/25", + ) + + def formula(person, period, parameters): + dwp = parameters(period).gov.dwp + tolerance = ( + dwp.universal_credit.elements.child.severely_disabled.benefit_amount_tolerance + * WEEKS_IN_YEAR + ) + highest_dla_care = ( + person("dla_sc", period) + >= dwp.dla.self_care.higher * WEEKS_IN_YEAR - tolerance + ) + enhanced_pip_daily_living = ( + person("pip_dl", period) + >= dwp.pip.daily_living.enhanced * WEEKS_IN_YEAR - tolerance + ) + return person("is_blind", period) | highest_dla_care | enhanced_pip_daily_living diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py index 189e875499..33b5412777 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py @@ -7,7 +7,7 @@ class uc_individual_severely_disabled_child_element(Variable): label = "Universal Credit severely disabled child element" definition_period = YEAR unit = GBP - defined_for = "is_severely_disabled_for_benefits" + defined_for = "meets_uc_higher_disabled_child_condition" reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/24/2" def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py index 578b8a388f..0ad1ebdc85 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py @@ -15,4 +15,5 @@ def formula(person, period, parameters): child = person( "is_child_or_qualifying_young_person_for_universal_credit", period ) & ~person("is_uc_claimant", period) - return (child * p.amount) * MONTHS_IN_YEAR + higher_condition = person("meets_uc_higher_disabled_child_condition", period) + return (child * ~higher_condition * p.amount) * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py index 2a2ca1b08d..241d772e78 100644 --- a/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py @@ -30,10 +30,10 @@ class is_severely_disabled_for_benefits(Variable): "use the stored value instead of this formula " "(policyengine-uk-data#494 aligns its definition with this one). " "The legacy severe disability premium has its own, wider list " - "(receives_severe_disability_premium_qualifying_benefit). The flag " - "also gates the Universal Credit higher disabled child addition, " - "whose condition (UC Regs 2013 reg 24(2)(b)) adds blindness and has " - "no armed forces independence payment limb; that is not yet separated." + "(receives_severe_disability_premium_qualifying_benefit). Universal " + "Credit uses its separate higher disabled child condition " + "(meets_uc_higher_disabled_child_condition), which includes certified " + "blindness and excludes attendance allowance and AFIP." ) definition_period = YEAR reference = ( From 04c843d1c5c62660443a6431901eb36ba0937a99 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 03:34:47 +0100 Subject: [PATCH 072/224] Correct the Northern Ireland UC citation --- .../meets_uc_higher_disabled_child_condition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py index 79b05331ab..beaee1644c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py @@ -21,7 +21,7 @@ class meets_uc_higher_disabled_child_condition(Variable): ) reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/24", - "https://www.legislation.gov.uk/uksi/2016/216/regulation/25", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/25", ) def formula(person, period, parameters): From 11406ad805e1e07c7532e6cad841595873cefee2 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 05:04:39 +0100 Subject: [PATCH 073/224] Exclude Child Benefit from GB pensioner CTR income --- changelog.d/2111.fixed.md | 1 + ...tax_reduction_pensioner_child_benefit.yaml | 208 +++++++++++++++++ ..._tax_reduction_child_benefit_properties.py | 216 ++++++++++++++++++ ...tax_reduction_pension_credit_properties.py | 12 +- ...council_tax_reduction_applicable_income.py | 23 +- 5 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2111.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py diff --git a/changelog.d/2111.fixed.md b/changelog.d/2111.fixed.md new file mode 100644 index 0000000000..b696059d2b --- /dev/null +++ b/changelog.d/2111.fixed.md @@ -0,0 +1 @@ +- Excluded Child Benefit from Great Britain's pensioner Council Tax Reduction general-rule income, preserving working-age and statutory-pay treatment. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml new file mode 100644 index 0000000000..0eaf16432d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml @@ -0,0 +1,208 @@ +# Current GB pensioner income exclusions: England Sch1 para16(1)(j)(vi), +# Wales Sch1 para10(1)(j)(vi), Scotland reg27(1)(j)(vii). +# Model2026: eldest Child Benefit27.05*52=1406.60; aged allowance256*52=13312. +# These genuine claim/takeup inputs use the general rules, not PC overrides. +- name: England pensioner Child Benefit is excluded before the CTR taper + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + income_tax: [686, 0] + council_tax_reduction_pensioner: true + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + council_tax_reduction_applicable_income: 15314 + council_tax_reduction_applicable_amount: 13312 + # 3000 - .2*(16000 - 686 - 13312) = 2599.60. + council_tax_benefit: 2599.60 + council_tax_reduction: 2599.60 + +- name: Scottish pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: SCOTLAND + local_authority: GLASGOW_CITY + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Welsh pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Not claiming Child Benefit preserves the pensioner general-rule income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + would_claim_child_benefit: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 0 + council_tax_reduction_applicable_income: 15314 + council_tax_reduction: 2599.60 + +- name: Excluding pensioner Child Benefit retains the zero floor and liability cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, personal_pension_contributions: 4000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 5000 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: A Guarantee Credit family still uses the whole-income disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 80, is_parent: true} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: true, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + in_receipt_of_guarantee_credit: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: Northern Ireland intermediate income is preserved outside this GB change + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_applicable_income: 13406.60 + council_tax_reduction_scheme_supported: false + council_tax_reduction: 0 + +- name: Pensioner Child Benefit exclusion keeps SSP and SMP counted once as earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: {age: 70, is_parent: true, state_pension: 12000, statutory_sick_pay: 1000} + partner: {age: 30, is_parent: true, gender: FEMALE, statutory_maternity_pay: 2000} + baby: {age: 0} + benunits: + family: + members: [older, partner, baby] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [older, partner, baby] + region: LONDON + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + income_tax: [0, 0, 0] + council_tax_reduction_pensioner_earnings_disregard: 520 + # Default marriage election removes the otherwise86tax. + # 12000 + 1000 + 2000 - 0 tax - 10*52 disregard = 14480. + council_tax_reduction_applicable_income: 14480 + council_tax_reduction: 3000 diff --git a/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py new file mode 100644 index 0000000000..b041c55dca --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py @@ -0,0 +1,216 @@ +"""Pensioner Child Benefit exclusion and unchanged adjacent CTR contracts. + +These model2026 tests use actual Child Benefit claims and family incomes, not +forced applicable income, pensioner status or reduction awards. The 20% cash +relation is conditional on an interior award; the existing floor/cap still apply. +""" + +from copy import deepcopy + +import numpy as np +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +COUNTRIES = { + "ENGLAND": ("LONDON", "MAIDSTONE"), + "SCOTLAND": ("SCOTLAND", "GLASGOW_CITY"), + "WALES": ("WALES", "CARDIFF"), +} + + +def family(country="ENGLAND", pension=16000, age=70, children=1, council_tax=4000): + region, authority = COUNTRIES.get(country, ("NORTHERN_IRELAND", "BELFAST")) + people = {"parent": {"age": {YEAR: age}, "is_parent": {YEAR: True}}} + people["parent"][ + "state_pension" if age >= 67 else "employment_income_before_lsr" + ] = {YEAR: pension} + for index in range(children): + people[f"child{index}"] = {"age": {YEAR: 9 + index}} + return { + "people": people, + "benunits": { + "family": { + "members": list(people), + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + }, + "households": { + "home": { + "members": list(people), + "region": {YEAR: region}, + "local_authority": {YEAR: authority}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: council_tax}, + "savings": {YEAR: 0}, + } + }, + } + + +def calculate(data, claim, status_first=False): + data = deepcopy(data) + for unit in data["benunits"].values(): + unit["would_claim_child_benefit"] = {YEAR: claim} + sim = Simulation(situation=data) + first = ( + "council_tax_reduction_pensioner" + if status_first + else "council_tax_reduction_applicable_income" + ) + sim.calculate(first, YEAR) + names = [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_benefit", + "council_tax_reduction", + "household_net_income", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + ] + return {name: np.asarray(sim.calculate(name, YEAR)) for name in names} + + +@settings(max_examples=4, deadline=None, derandomize=True) +@given( + st.sampled_from(sorted(COUNTRIES)), + st.integers(15000, 19000), + st.integers(1, 2), + st.booleans(), +) +def test_pensioner_ctr_is_invariant_to_actual_child_benefit_claim( + country, pension, children, status_first +): + data = family(country, pension, children=children) + paid = calculate(data, True, status_first) + unpaid = calculate(data, False, not status_first) + assert paid["child_benefit"][0] > 0 + assert unpaid["child_benefit"][0] == 0 + assert paid["council_tax_reduction_pensioner"][0] + assert not paid["in_receipt_of_guarantee_credit"][0] + assert not paid["in_receipt_of_savings_credit_only"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"], + unpaid["council_tax_reduction_applicable_income"], + atol=0.01, + rtol=0, + ) + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + # The payable award reaches the household consumer. No other benefit/tax + # changes in these owner-occupier, no-PC/no-UC, below-HICBC families. + np.testing.assert_allclose( + paid["household_net_income"] - unpaid["household_net_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + # Named old-law counterfactual: before the exclusion, an interior award + # counted this cash and lost exactly 20% of it. The new award is not clipped. + income = unpaid["council_tax_reduction_applicable_income"][0] + amount = unpaid["council_tax_reduction_applicable_amount"][0] + liability = 4000 + old_award = max( + 0, liability - 0.2 * max(0, income + paid["child_benefit"][0] - amount) + ) + assert income > amount and old_award > 0 + assert ( + abs( + paid["council_tax_reduction"][0] + - old_award + - 0.2 * paid["child_benefit"][0] + ) + < 0.02 + ) + + +def test_working_age_and_northern_ireland_keep_counting_child_benefit(): + for country, authority in [ + ("SCOTLAND", "GLASGOW_CITY"), + ("WALES", "CARDIFF"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("NORTHERN_IRELAND", "BELFAST"), + ]: + age = 70 if country == "NORTHERN_IRELAND" else 40 + data = family(country, 18000, age=age) + data["households"]["home"]["local_authority"] = {YEAR: authority} + paid, unpaid = calculate(data, True), calculate(data, False, True) + assert paid["child_benefit"][0] > 0 + if age == 40: + assert not paid["council_tax_reduction_pensioner"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + assert ( + paid["council_tax_reduction"][0] + <= unpaid["council_tax_reduction"][0] + 0.01 + ) + if authority == "OXFORD": + # Oxford already removes CB before its own band selection. + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + + +def test_own_family_pensioner_mask_and_query_order_ignore_outside_family(): + data = family() + data["people"].update( + { + "other": {"age": {YEAR: 40}, "employment_income_before_lsr": {YEAR: 18000}}, + "other_child": {"age": {YEAR: 7}}, + } + ) + data["benunits"]["other_family"] = { + "members": ["other", "other_child"], + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + data["households"]["home"]["members"] += ["other", "other_child"] + paid, unpaid = calculate(data, True), calculate(data, False, True) + np.testing.assert_array_equal( + paid["council_tax_reduction_pensioner"], [True, False] + ) + delta = ( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"] + ) + np.testing.assert_allclose(delta, [0, paid["child_benefit"][1]], atol=0.02, rtol=0) + reversed_data = deepcopy(data) + reversed_data["people"] = dict(reversed(list(data["people"].items()))) + reversed_data["benunits"] = dict(reversed(list(data["benunits"].items()))) + for unit in reversed_data["benunits"].values(): + unit["members"] = list(reversed(unit["members"])) + reverse = calculate(reversed_data, True, True) + for name in [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_benefit", + ]: + np.testing.assert_allclose(reverse[name][::-1], paid[name], atol=0.02, rtol=0) + np.testing.assert_allclose( + reverse["council_tax_reduction"], + paid["council_tax_reduction"], + atol=0.02, + rtol=0, + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index e6d1de97c7..811373c441 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -23,8 +23,8 @@ nothing otherwise. 4. Differential: everyone else keeps the previous formula, max(0, L - 0.2 x max(0, I - A) - ND) when household savings are within 16,000, where I is the - income definition on main before this change less the schemes' pensioner - earnings disregard, both recomputed here (the income from its components, + income definition on main before this change less pensioners' Child Benefit + and the schemes' pensioner earnings disregard, recomputed here (income from its components, the disregard from the statutory sums) rather than read from the variables under test. 5. Metamorphic: a guarantee credit recipient's CTR does not change when the @@ -264,7 +264,8 @@ def situation( # council_tax_reduction_applicable_income on main before this change (1c5b4d04): # these incomes and benefits, less income tax, National Insurance and half of # pension contributions, floored at zero. Since #2033, a pensioner family's -# earnings disregard also comes off before the floor. +# earnings disregard also comes off before the floor. These GB families now +# exclude Child Benefit on the pensioner general-rules route as well. MAIN_INCOME_COMPONENTS = [ "employment_income", "self_employment_income", @@ -362,6 +363,11 @@ def benunit_total(names): values["income_under_main_definition"] = np.maximum( 0, benunit_total(MAIN_INCOME_COMPONENTS) + - np.where( + values["council_tax_reduction_pensioner"], + benunit_total(["child_benefit"]), + 0, + ) - benunit_total(MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), 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 55575e1987..5f09f4c4a8 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,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_applicable_income(Variable): @@ -12,7 +13,9 @@ class council_tax_reduction_applicable_income(Variable): "use the Secretary of State's Pension Credit assessment of income, plus " "the savings credit payable, where the award is savings credit only. " "Otherwise a pension-age family's net earnings are reduced by the " - "pensioner earnings disregards. The other adjustments those " + "pensioner earnings disregards. Great Britain's pensioner schemes " + "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." ) @@ -21,8 +24,10 @@ class council_tax_reduction_applicable_income(Variable): reference = [ "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/13", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/7", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", "https://www.legislation.gov.uk/ssi/2012/319/regulation/24", "https://www.legislation.gov.uk/ssi/2012/319/regulation/25", "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", @@ -100,6 +105,22 @@ def formula(benunit, period, parameters): benefits = add_for_members( benunit, period, benunit_means_tested_benefits, members ) + country = benunit.household("country", period) + in_great_britain = ( + (country == Country.ENGLAND) + | (country == Country.SCOTLAND) + | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + # England Sch 1 para 16(1)(j)(vi), Wales Sch 1 para 10(1)(j)(vi) + # and Scotland reg 27(1)(j)(vii) exclude this exact BenUnit award. + # Remove it before the general-rules floor. SSP/SMP remain counted + # in personal_benefits as earnings; working-age/NI income is unchanged. + benefits -= where( + pensioner & in_great_britain, + add_for_members(benunit, period, ["child_benefit"], members), + 0, + ) income = add_for_members(benunit, period, income_components, members) personal_benefit_income = add_for_members( benunit, period, personal_benefits, members From be2d8e459ce395e10e7b3b526d92d987137dad26 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 06:51:44 +0100 Subject: [PATCH 074/224] Keep WTC worker age and hours conditions together --- changelog.d/2042.fixed.md | 1 + docs/book/programs/gov/dwp/tax-credits.md | 20 +- .../working_tax_credit/min_age.yaml | 9 + ...in_age_without_children_or_disability.yaml | 9 + .../family/tax_credits_working_hours.yaml | 476 ++++++++++++++++++ ...rking_tax_credit_eligibility_properties.py | 116 +++++ .../variables/gov/dwp/is_WTC_eligible.py | 43 ++ 7 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2042.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml create mode 100644 policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py diff --git a/changelog.d/2042.fixed.md b/changelog.d/2042.fixed.md new file mode 100644 index 0000000000..d99e12b288 --- /dev/null +++ b/changelog.d/2042.fixed.md @@ -0,0 +1 @@ +- Corrected Working Tax Credit eligibility so age, disability and hours conditions applied to the same claimant or partner, while preserving the parental combined-hours and partner-exception routes. diff --git a/docs/book/programs/gov/dwp/tax-credits.md b/docs/book/programs/gov/dwp/tax-credits.md index eac83f4600..2ee1aa23e1 100644 --- a/docs/book/programs/gov/dwp/tax-credits.md +++ b/docs/book/programs/gov/dwp/tax-credits.md @@ -47,7 +47,25 @@ from the means test, gated by the `would_claim_WTC` take-up input. The `min_hours` tree (`min_hours/default.yaml`, `lower.yaml`, `old_age.yaml`, `couple_with_children.yaml`) encodes the bands that gate WTC eligibility — typically 30 hours/week for adults with no kids, 16 for -lone parents, with a lower band for over-60s. +lone parents, with a lower band for workers aged 60 or over. + +For the joint-claim rules from 6 April 2012, an individual's own age or +disability must qualify that individual's hours. Two childless workers cannot +pool 15 hours each to meet the ordinary 30-hour route, and a nonworking +partner cannot lend their age or disability to the worker. Parents can combine +24 hours, with at least one claimant working 16 hours. Hours worked by a +child or other dependant do not count towards that total. The `min_age` and +`min_age_without_children_or_disability` parameters provide the 16-year and +25-year thresholds; the existing `min_hours.old_age` parameter provides 60. +Native annual formula selection retains the earlier formula for a year whose +start precedes 6 April 2012; this change does not certify all earlier rules. + +`is_disabled_for_benefits` remains a generic proxy for the statutory disabled +worker conditions. For parents, the model also retains its existing proxy +for an exception based on the other partner's disability. These flags do not +encode all regulation 9 or regulation 13 tests, incapacity, hospital or prison +status, carer's allowances, or the expected four-week and paid-work conditions. +The reported-award, migration, take-up and scheme-closure rules remain separate. ## Child Tax Credit (CTC) diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml new file mode 100644 index 0000000000..7d80b22d1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the specified Working Tax Credit 16-hour and parental routes. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 16 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml new file mode 100644 index 0000000000..6a50fc0c40 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the ordinary Working Tax Credit 30-hour route without children or a qualifying disability. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 25 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml new file mode 100644 index 0000000000..b4c1d9e90e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml @@ -0,0 +1,476 @@ +# Same-worker WTC conditions, SI2002/2005reg4 (joint variations from6April2012). +# Amounts use unchanged2024 model parameters; no eligibility/element/final-award overrides. +# Generic disability and other-disabled-partner exception remain inherited proxies. +- name: A childless worker cannot borrow a nonworking partners age sixty + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged sixty qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A thirty hour worker cannot borrow a partners age twenty five + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 24 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged twenty five qualifies on their own thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 5950 +- name: Two childless fifteen hour workers do not make one thirty hour worker + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A childless worker cannot borrow a nonworking partners disability + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A disabled worker qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 8870 +- name: A worker aged fifty nine still needs thirty hours without children + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 59 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents may combine sixteen and eight hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 3000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 416 + employment_income: 2000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: Joint parents twelve hours each do not meet the individual sixteen hour limb + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents sixteen and seven hours do not meet twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 364 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Parents retain the other disabled partner exception proxy + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: true + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A childs eight hours cannot complete the parents twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 15 + hours_worked: 416 + employment_income: 1000 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: An unreported historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 0 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A closed historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + legacy_benefits_closed: true + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A single ordinary worker qualifies at thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: true + working_tax_credit: 3450 +- name: A lone parent retains the sixteen hour route + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 30 + hours_worked: 832 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: true + child_0: + age: 10 + benunits: + family: + members: + - worker + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A single worker below the own age threshold does not qualify + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 24 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: false + working_tax_credit: 0 diff --git a/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py new file mode 100644 index 0000000000..45c54a012a --- /dev/null +++ b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py @@ -0,0 +1,116 @@ +"""WTC worker conditions stay with their person and benefit unit. + +These controls use genuine work/claim inputs and literal final-award oracles. +They exercise composition and parameter hooks, rather than duplicate the +production predicate. Disability retains the model's existing generic proxy. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +YEAR = 2024 + + +def situation(families, reverse=False): + people, benunits, households = {}, {}, {} + for i, (adults, children) in enumerate(families): + names = [] + for j, inputs in enumerate(adults + children): + name = f"person_{i}_{j}" + people[name] = {key: {YEAR: value} for key, value in inputs.items()} + people[name]["is_claimant_or_partner"] = {YEAR: j < len(adults)} + names.append(name) + if reverse: + names.reverse() + benunits[f"family_{i}"] = {"members": names} + households[f"household_{i}"] = {"members": names} + if reverse: + people = dict(reversed(list(people.items()))) + return {"people": people, "benunits": benunits, "households": households} + + +def worker(age, hours, earnings=5_000, **inputs): + return { + "age": age, + "hours_worked": hours * 52, + "employment_income": earnings if hours else 0, + **inputs, + } + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_worker_conditions_do_not_move_between_people_or_families(reverse): + families = [ + ([worker(40, 0, working_tax_credit_reported=1), worker(24, 30)], []), + ([worker(40, 0, working_tax_credit_reported=1), worker(25, 30)], []), + ( + [ + worker(30, 16, is_parent=True, working_tax_credit_reported=1), + worker(28, 0, is_parent=True), + ], + [worker(15, 8, earnings=1_000)], + ), + ( + [ + worker( + 30, + 16, + earnings=3_000, + is_parent=True, + working_tax_credit_reported=1, + ), + worker(28, 8, earnings=2_000, is_parent=True), + ], + [{"age": 10}], + ), + ] + simulation = Simulation(situation=situation(families, reverse)) + # Request the final award first, so an eligibility-only cache cannot hide + # a cross-person or cross-family error in the cash consumer. + np.testing.assert_allclose( + simulation.calculate("working_tax_credit", YEAR), + [0, 5_950, 0, 4_935], + atol=0.01, + rtol=0, + ) + np.testing.assert_array_equal( + simulation.calculate("is_WTC_eligible", YEAR), [False, True, False, True] + ) + + +@pytest.mark.parametrize( + "age,hours,disabled,parameter,value,baseline_cash", + [ + (25, 30, False, "min_age_without_children_or_disability", 26, 3_450), + (16, 16, True, "min_age", 17, 6_370), + (60, 16, False, "min_hours.old_age", 61, 2_435), + ], +) +def test_worker_age_parameters_change_actual_cash_awards( + age, hours, disabled, parameter, value, baseline_cash +): + inputs = situation( + [ + ( + [ + worker( + age, + hours, + is_disabled_for_benefits=disabled, + working_tax_credit_reported=1, + ) + ], + [], + ) + ] + ) + baseline = Simulation(situation=inputs) + reform = Simulation( + situation=inputs, + reform={f"gov.dwp.tax_credits.working_tax_credit.{parameter}": value}, + ) + assert baseline.calculate("working_tax_credit", YEAR)[0] == baseline_cash + assert reform.calculate("working_tax_credit", YEAR)[0] == 0 diff --git a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py index 84b2d6c770..9e18d4d049 100644 --- a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py @@ -51,3 +51,46 @@ def formula(benunit, period, parameters): | (medium_req & meets_medium) | (higher_req & meets_higher) ) & already_claiming + + def formula_2012_04_06(benunit, period, parameters): + WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit + person = benunit.members + claimant = person("is_claimant_or_partner", period) + age = person("age", period) + hours = person("weekly_hours", period) + disabled = person("is_disabled_for_benefits", period) + works_lower_hours = claimant & (hours >= WTC.min_hours.lower) + working_at_minimum_age = works_lower_hours & (age >= WTC.min_age) + disabled_worker = benunit.any(working_at_minimum_age & disabled) + older_worker = benunit.any(works_lower_hours & (age >= WTC.min_hours.old_age)) + + responsible_for_child = benunit( + "is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit", + period, + ) + ordinary_worker = ~responsible_for_child & benunit.any( + claimant + & (age >= WTC.min_age_without_children_or_disability) + & (hours >= WTC.min_hours.default) + ) + lone_parent = ( + benunit("is_single", period) + & responsible_for_child + & benunit.any(working_at_minimum_age) + ) + joint_parents = benunit("is_couple", period) & responsible_for_child + meets_joint_hours = ( + benunit.sum(hours * claimant) >= WTC.min_hours.couple_with_children + ) & benunit.any(working_at_minimum_age) + # Preserve the existing other-disabled-partner exception proxy. This + # generic flag does not encode every reg 13 exception or disability test. + partner_exception_proxy = benunit.any(claimant & disabled) & benunit.any( + working_at_minimum_age + ) + parents = joint_parents & (meets_joint_hours | partner_exception_proxy) + already_claiming = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) & ~benunit("legacy_benefits_closed", period) + return ( + disabled_worker | older_worker | ordinary_worker | lone_parent | parents + ) & already_claiming From f077c015d278287ac2d3a3282937349a935418cf 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 09:24:23 +0100 Subject: [PATCH 075/224] Fix Child Tax Credit family element birth-date eligibility --- changelog.d/2050.fixed.md | 1 + docs/book/programs/gov/dwp/tax-credits.md | 35 ++- ..._element_birth_date_condition_applies.yaml | 13 + .../family_element_born_before.yaml | 12 + .../dwp/tax_credits/ctc_family_element.yaml | 83 ++++++ .../tests/test_ctc_family_element.py | 241 ++++++++++++++++++ .../variables/gov/dwp/CTC_family_element.py | 21 +- 7 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2050.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml create mode 100644 policyengine_uk/tests/test_ctc_family_element.py diff --git a/changelog.d/2050.fixed.md b/changelog.d/2050.fixed.md new file mode 100644 index 0000000000..4b1f68f8ac --- /dev/null +++ b/changelog.d/2050.fixed.md @@ -0,0 +1 @@ +- Restricted the Child Tax Credit family element to families responsible for a qualifying child or young person born before 6 April 2017, while preserving earlier supported years. diff --git a/docs/book/programs/gov/dwp/tax-credits.md b/docs/book/programs/gov/dwp/tax-credits.md index eac83f4600..1830685284 100644 --- a/docs/book/programs/gov/dwp/tax-credits.md +++ b/docs/book/programs/gov/dwp/tax-credits.md @@ -59,11 +59,44 @@ benunit worked. PolicyEngine computes it via `ctc_entitlement`, gated by | Element | Parameter | |---------|-----------| -| Family | `family_element.yaml` (extinct from 2017 for new claims) | +| Family | `family_element.yaml` (one amount, subject to the birth-date condition below) | | Per qualifying child | `child_element.yaml` | | Disabled child addition | `dis_child_element.yaml` | | Severely disabled child addition | `severe_dis_child_element.yaml` | +### Family element birth-date condition + +From 6 April 2017, the family element is included only if a claimant or +joint claimant is responsible for a qualifying child or qualifying young +person born **strictly before 6 April 2017**. A child born on that date +does not qualify. One qualifying child is enough for the single family +amount; an older adult or an excluded child cannot unlock it. The model +uses its existing CTC child/QYP responsibility mask, including its existing +looked-after-child and own-right-benefit approximations. + +`CTC_family_element` applies this condition separately from the individual +child element's two-child limit. Its activation and cutoff parameters live +under `child_tax_credit/eligibility/`; reforming them does not change the +individual-element cutoff. The existing flat £545 amount is unchanged. +Before the condition applies, supported model years retain the prior flat +family amount. The false activation value at 2015-01-01 anchors model +coverage, rather than statutory commencement. Annual parameters use the +existing April sampling convention; this is not a part-year award model. + +The rule reuses `birth_day`: a supplied valid `date_of_birth` must agree +with legal age on 6 October. A missing or zero date uses the existing +annual age and months-since-birthday fallback; `birth_year` is not a +replacement. Those conventions are not exact birth-date observations. +Explicit family-element and final-amount inputs retain the ordinary model +override contract and are not evidence of legal entitlement. Continuing +claims, income tests, minimum awards, other elements and 2025 closure are +unchanged; this update does not audit daily statutory award rounding. + +Historical coverage remains incomplete: this change addresses the +supported 2015+ birth-date rule only. The pre-2011 baby addition and +2003–2014 coverage remain follow-up work under issue #2050; no raw +historical values or annual-conversion rules were extended. + ### Two-child limit Since April 2017 the child element has been restricted to the first two diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml new file mode 100644 index 0000000000..4a1b580765 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml @@ -0,0 +1,13 @@ +description: Whether the Child Tax Credit family element requires a responsible child or qualifying young person born before the family-element cutoff. The false 2015 value anchors existing model coverage, not legal commencement. +values: + 2015-01-01: false + 2017-04-06: true +metadata: + unit: bool + period: year + label: Child Tax Credit family element birth date condition applies + reference: + - title: The Child Tax Credit (Amendment) Regulations 2017, regulations 1 and 4(b) + href: https://www.legislation.gov.uk/uksi/2017/387/regulation/4/made + - title: The Child Tax Credit (Amendment) Regulations 2017, regulation 1 + href: https://www.legislation.gov.uk/uksi/2017/387/regulation/1/made diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml new file mode 100644 index 0000000000..ae986873f6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml @@ -0,0 +1,12 @@ +description: A responsible child or qualifying young person must be born strictly before this YYYYMMDD date for the Child Tax Credit family element, when its birth date condition applies. +values: + 2017-04-06: 20170406 +metadata: + unit: date + period: year + label: Child Tax Credit family element cutoff date of birth + reference: + - title: The Child Tax Credit Regulations 2002, regulation 7(2)(a) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/7 + - title: Tax Credits Act 2002, section 9(2)(a) + href: https://www.legislation.gov.uk/ukpga/2002/21/section/9 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml new file mode 100644 index 0000000000..10e99d0342 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml @@ -0,0 +1,83 @@ +# SI 2017/387 regs 1 and 4(b): strict responsible-child DOB condition. +# Existing 2024 child element 3455; family element 545; zero-income continuing claims. +- name: A child born on the family cutoff receives no family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170406 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 3455 + tax_credits: 3455 + CTC_family_element: 0 + CTC_child_element: 3455 +- name: A child born the day before the family cutoff retains one family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170405 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 4000 + tax_credits: 4000 + CTC_family_element: 545 + CTC_child_element: 3455 +- name: A child born after the family cutoff receives no family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170407 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 3455 + tax_credits: 3455 + CTC_family_element: 0 + CTC_child_element: 3455 diff --git a/policyengine_uk/tests/test_ctc_family_element.py b/policyengine_uk/tests/test_ctc_family_element.py new file mode 100644 index 0000000000..3de929f8b0 --- /dev/null +++ b/policyengine_uk/tests/test_ctc_family_element.py @@ -0,0 +1,241 @@ +"""Family-only DOB eligibility and its inherited annual/input contracts. + +SI 2002/2007 reg 7(2)(a), amended by SI 2017/387 reg 4(b), requires a +responsible child/QYP born strictly before 6 April 2017. The 2024 cash +oracles use unchanged child3455/family545, zero income and a reported +continuing claim. They do not audit daily statutory award rounding. +""" + +from pathlib import Path + +import pytest + +from policyengine_core.parameters.helpers import load_parameter_file +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +PREFIX = "gov.dwp.tax_credits.child_tax_credit.eligibility." + + +def situation(children, year=2024, parent=None, benunit_inputs=None): + facts = {"parent": {"age": 35, "child_tax_credit_reported": 1}} + facts["parent"].update(parent or {}) + facts.update(children) + people = { + name: {key: {year: value} for key, value in person.items()} + for name, person in facts.items() + } + members = list(people) + return { + "people": people, + "benunits": { + "family": { + "members": members, + "would_claim_uc": {year: False}, + **{key: {year: value} for key, value in (benunit_inputs or {}).items()}, + } + }, + "households": {"home": {"members": members}}, + } + + +def child(born=20170406, age=7, **other): + return {"child": {"age": age, "date_of_birth": born, **other}} + + +def assert_cash(sim, expected, year=2024): + assert sim.calculate("child_tax_credit", year)[0] == pytest.approx( + expected, abs=0.01 + ) + assert sim.calculate("tax_credits", year)[0] == pytest.approx(expected, abs=0.01) + + +def test_raw_commencement_and_annual_mapping_preserve_prior_supported_years(): + root = Path(__file__).resolve().parents[1] / "parameters" + applies = load_parameter_file( + str( + root + / "gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml" + ), + name="applies", + ) + cutoff = load_parameter_file( + str( + root + / "gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml" + ), + name="cutoff", + ) + assert ( + applies("2015-01-01") is False + ) # Existing coverage anchor, not legal commencement. + assert applies("2017-04-05") is False + assert applies("2017-04-06") is True + assert cutoff.values_list[-1].instant_str == "2017-04-06" + assert cutoff("2017-04-06") == 20170406 + system = CountryTaxBenefitSystem() + for year, expected in [(2015, False), (2016, False), (2017, True), (2024, True)]: + p = system.parameters( + str(year) + ).gov.dwp.tax_credits.child_tax_credit.eligibility + assert p.family_element_birth_date_condition_applies == expected + + +@pytest.mark.parametrize("year", [2015, 2016]) +def test_prior_supported_years_keep_the_existing_flat_family_element(year): + sim = Simulation(situation=situation(child((year - 1) * 10000 + 406, 1), year)) + assert sim.calculate("CTC_family_element", year)[0] == 545 + + +def test_annual_2017_uses_the_qualified_april_condition(): + sim = Simulation(situation=situation(child(20170406, 0), 2017)) + assert_cash(sim, 2780, 2017) # Unchanged 2017 child-element baseline; family0. + assert sim.calculate("CTC_family_element", 2017)[0] == 0 + + +def test_a_genuine_qualifying_young_person_can_unlock_the_family_element(): + sim = Simulation( + situation=situation( + child(20060901, 18, current_education="UPPER_SECONDARY"), parent={"age": 45} + ) + ) + assert sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", 2024 + ).tolist() == [False, True] + assert_cash(sim, 4000) + + +def test_an_older_excluded_member_cannot_unlock_a_younger_childs_family_element(): + # Existing looked-after proxy excludes the older child; its wider legal + # limitations are unchanged. Adult claimants are also outside the mask. + sim = Simulation( + situation=situation( + { + "older": { + "age": 10, + "date_of_birth": 20140405, + "is_looked_after_by_local_authority": True, + }, + **child(), + }, + parent={"age": 40}, + ) + ) + assert sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", 2024 + ).tolist() == [False, False, True] + assert_cash(sim, 3455) + assert sim.calculate("CTC_family_element", 2024)[0] == 0 + + +def test_multiple_qualifying_children_receive_one_flat_family_amount(): + sim = Simulation( + situation=situation( + { + "older": {"age": 10, "date_of_birth": 20140405}, + "younger": {"age": 8, "date_of_birth": 20160405}, + } + ) + ) + assert_cash(sim, 7455) # 2 * 3455 + one545, not545perchild. + assert sim.calculate("CTC_family_element", 2024)[0] == 545 + + +@pytest.mark.parametrize("months,expected", [(5, 3455), (6, 3455), (7, 4000)]) +def test_partial_dob_inputs_retain_the_existing_annual_age_fallback(months, expected): + sim = Simulation( + situation=situation( + child(0, 7, months_since_last_birthday=months), + parent={"date_of_birth": 19890101}, + ) + ) + assert_cash(sim, expected) + + +def test_birth_year_override_does_not_replace_the_existing_dob_helper(): + sim = Simulation(situation=situation(child(0, 7, birth_year=2000))) + assert_cash(sim, 3455) + + +@pytest.mark.parametrize( + "born,cutoff,expected,individual_exempt", + [(20170406, 20170407, 4000, False), (20170405, 20170405, 3455, True)], +) +def test_family_cutoff_reforms_do_not_move_the_individual_element_cutoff( + born, cutoff, expected, individual_exempt +): + sim = Simulation( + situation=situation(child(born)), + reform={PREFIX + "family_element_born_before": cutoff}, + ) + assert_cash(sim, expected) + assert ( + bool(sim.calculate("is_CTC_child_limit_exempt", 2024)[1]) is individual_exempt + ) + + +def test_family_activation_is_independently_reformable(): + sim = Simulation( + situation=situation(child()), + reform={PREFIX + "family_element_birth_date_condition_applies": False}, + ) + assert_cash(sim, 4000) + + +def test_individual_element_exception_does_not_waive_the_family_condition(): + sim = Simulation( + situation=situation(child()), + reform={"gov.contrib.two_child_limit.age_exemption.child_tax_credit": 10}, + ) + assert bool(sim.calculate("is_CTC_child_limit_exempt", 2024)[1]) + assert_cash(sim, 3455) + assert sim.calculate("CTC_family_element", 2024)[0] == 0 + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_family_boundaries_are_local_across_units_member_and_request_order(reverse): + people, benunits = {}, {} + for name, born in [("before", 20170405), ("on", 20170406)]: + inputs = situation(child(born)) + names = [name + "_parent", name + "_child"] + people.update( + {new: value for new, value in zip(names, inputs["people"].values())} + ) + benunits[name] = { + "members": names[::-1] if reverse else names, + "would_claim_uc": {2024: False}, + } + if reverse: + people = dict(reversed(list(people.items()))) + benunits = dict(reversed(list(benunits.items()))) + sim = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"shared": {"members": list(people)}}, + } + ) + if reverse: + sim.calculate("is_CTC_child_limit_exempt", 2024) + sim.calculate("CTC_family_element", 2024) + for variable in ["child_tax_credit", "tax_credits"]: + values = dict( + zip(sim.populations["benunit"].ids, sim.calculate(variable, 2024)) + ) + assert values == pytest.approx({"before": 4000, "on": 3455}, abs=0.01) + + +@pytest.mark.parametrize( + "variable,amount", [("CTC_family_element", 123), ("child_tax_credit", 222)] +) +def test_explicit_amount_inputs_remain_overrides_not_entitlement_proof( + variable, amount +): + sim = Simulation(situation=situation(child(), benunit_inputs={variable: amount})) + assert sim.calculate(variable, 2024)[0] == amount + if variable == "CTC_family_element": + assert_cash(sim, 3578) # 3455 + explicitly supplied123. + + +def test_scheme_closure_still_overrides_an_old_childs_family_qualification(): + sim = Simulation(situation=situation(child(20170405, 8), 2025)) + assert_cash(sim, 0, 2025) diff --git a/policyengine_uk/variables/gov/dwp/CTC_family_element.py b/policyengine_uk/variables/gov/dwp/CTC_family_element.py index 6d4b74d0ad..07babc5bdb 100644 --- a/policyengine_uk/variables/gov/dwp/CTC_family_element.py +++ b/policyengine_uk/variables/gov/dwp/CTC_family_element.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class CTC_family_element(Variable): @@ -6,11 +7,23 @@ class CTC_family_element(Variable): entity = BenUnit label = "CTC entitlement in the Family Element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 9" + reference = [ + "https://www.legislation.gov.uk/ukpga/2002/21/section/9", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/7", + ] unit = GBP defined_for = "is_CTC_eligible" def formula(benunit, period, parameters): - return parameters( - period - ).gov.dwp.tax_credits.child_tax_credit.elements.family_element + p = parameters(period).gov.dwp.tax_credits.child_tax_credit + if not p.eligibility.family_element_birth_date_condition_applies: + return p.elements.family_element + + person = benunit.members + qualifying_child = person( + "is_child_or_qualifying_young_person_for_child_tax_credit", period + ) + born_before = ( + birth_day(person, period) < p.eligibility.family_element_born_before + ) + return p.elements.family_element * benunit.any(qualifying_child & born_before) 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 076/224] 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 2ed33b126ae6b85ceb59ff9ec512a94e68d6fef6 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 12:19:32 +0100 Subject: [PATCH 077/224] Add normalized squared poverty-gap measures --- changelog.d/71.added.md | 1 + docs/book/validation/hbai.md | 23 ++++++ .../household/income/squared_poverty_gap.yaml | 80 +++++++++++++++++++ .../tests/test_squared_poverty_gap.py | 77 ++++++++++++++++++ .../income/squared_poverty_gap_ahc.py | 28 +++++++ .../income/squared_poverty_gap_bhc.py | 28 +++++++ 6 files changed, 237 insertions(+) create mode 100644 changelog.d/71.added.md create mode 100644 policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml create mode 100644 policyengine_uk/tests/test_squared_poverty_gap.py create mode 100644 policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py create mode 100644 policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py diff --git a/changelog.d/71.added.md b/changelog.d/71.added.md new file mode 100644 index 0000000000..9c5f66741f --- /dev/null +++ b/changelog.d/71.added.md @@ -0,0 +1 @@ +Added normalized annual BHC and AHC squared poverty-gap components and documented person-weighted aggregation and undefined poverty-line handling. diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 740c0ccdba..7644c80e41 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -54,6 +54,29 @@ PolicyEngine does the same. `hbai_household_net_income` is `hbai_household_net_i In FRS-based datasets, `housing_service_charges` contains the annualised property charges recorded in `CHRGAMT1`–`CHRGAMT9`: ground rent, chief rent, service charges, regular maintenance, caravan site rent, factoring charges, other regular charges and combined charges. `CHRGAMT2`, historically feu duty, is unused in the FRS 2024–25 dictionary. The combined-charge question is an alternative to recording individual amounts. Water and sewerage and rent are separate inputs; calculator users should supply each cost once, with service charges additional to the other housing-cost inputs. See the [FRS 2024–25 question instructions](https://doc.ukdataservice.ac.uk/doc/9563/mrdoc/pdf/9563_frs_2024-25_question_instructions_final.pdf), property charges section (pages 98–99), and its [variable dictionaries](https://doc.ukdataservice.ac.uk/doc/9563/mrdoc/ukda_data_dictionaries.zip). +## Normalized squared poverty gaps + +`squared_poverty_gap_bhc` and `squared_poverty_gap_ahc` are annual household, dimensionless components of the Foster–Greer–Thorbecke poverty-severity measure (FGT2). Each is the square of its existing nonnegative `poverty_gap_bhc` or `poverty_gap_ahc`, divided by the matching positive `poverty_line_bhc` or `poverty_line_ahc` **before squaring**: + +$$ +s_h = \left(\frac{\max(z_h-y_h,0)}{z_h}\right)^2, \qquad z_h>0. +$$ + +For example, income at or above the line gives zero, income halfway to the line gives 0.25, and zero income gives one. Scaling income and its line by the same positive factor leaves the component unchanged. These are normalized components, not GBP-squared poverty gaps or sums. + +The statistical definition follows the [World Bank PIP methodology, section 4.1](https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality). The variables retain the UK HBAI basis described above: negative BHC income is reset to zero, while AHC income can be negative. The AHC component can therefore exceed one. PIP's own negative-welfare preprocessing is not reproduced here, and these components are not an official DWP severity estimate. + +For a person-based population index, map each household's component to every person and take the existing survey-weighted **mean**, not a sum or a household-weighted mean. Given an already configured `Microsimulation` named `sim`: + +```python +component = sim.calculate("squared_poverty_gap_bhc", 2025, map_to="person") +fgt2 = component.mean(skipna=False) +``` + +For example, a one-person household with survey weight 2 and component 0.25, plus a three-person household with survey weight 1 and component 1, gives `(2 * 0.25 + 3 * 1) / (2 + 3) = 0.7`. A household-weighted mean would instead give 0.5. This tiny synthetic example is not a national estimate. + +A zero or negative poverty line has an undefined normalized component, returned as `NaN`. The explicit `skipna=False` preserves that invalid-domain signal in a weighted mean rather than silently dropping people; alternatively validate that all included poverty lines are positive before aggregation. No new income, poverty-line, equivalence, survey-weight or dataset assumptions are introduced. + ## Nowcasting/forecasting diff --git a/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml b/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml new file mode 100644 index 0000000000..386859e939 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml @@ -0,0 +1,80 @@ +# Normalized statistical components; input income and lines isolate the +# existing HBAI gap contract. No poverty gap or squared output is supplied. +- name: Income above both lines has zero squared poverty gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 120 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0 + squared_poverty_gap_ahc: 0 + +- name: Income exactly on the lines has zero squared poverty gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 100 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0 + squared_poverty_gap_ahc: 0 + +- name: Half-line income gives a normalized squared gap of one quarter + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 50 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 0.25 + poverty_gap_bhc: 50 + poverty_gap_ahc: 50 + +- name: Zero income gives a normalized squared gap of one + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 0 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 1 + squared_poverty_gap_ahc: 1 + +- name: Common currency rescaling preserves normalized squared gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 5000 + poverty_line_bhc: 10000 + poverty_line_ahc: 10000 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 0.25 + +- name: Each squared gap uses its matching income and poverty line + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 50 + poverty_line_bhc: 100 + poverty_line_ahc: 80 + rent: 90 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 2.25 + hbai_household_net_income_ahc: -40 + poverty_gap_bhc: 50 + poverty_gap_ahc: 120 + +- name: Existing BHC reset and negative AHC income are preserved + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: -50 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + rent: 50 + output: + squared_poverty_gap_bhc: 1 + squared_poverty_gap_ahc: 2.25 + hbai_household_net_income: 0 + hbai_household_net_income_ahc: -50 diff --git a/policyengine_uk/tests/test_squared_poverty_gap.py b/policyengine_uk/tests/test_squared_poverty_gap.py new file mode 100644 index 0000000000..d2d6f51541 --- /dev/null +++ b/policyengine_uk/tests/test_squared_poverty_gap.py @@ -0,0 +1,77 @@ +"""FGT2 components and person-weighted aggregation on tiny synthetic data. + +The .7 oracle differs from a .5 household-weighted mean: one person carries +weight2 and component.25; three people each carry weight1 and component1. +These are technical fixtures, not representative population estimates. +""" + +import numpy as np +import pytest + +from policyengine_uk import CountryTaxBenefitSystem, Microsimulation, Simulation + + +YEAR = 2025 + + +def situation(sizes, weights, lines, incomes): + people, families, households = {}, {}, {} + for h, (size, weight, line, income) in enumerate( + zip(sizes, weights, lines, incomes) + ): + members = [f"h{h}_p{i}" for i in range(size)] + for i, person in enumerate(members): + people[person] = {"age": {YEAR: 40 if i == 0 else 8}} + families[f"family{h}"] = {"members": members} + households[f"home{h}"] = { + "members": members, + "household_weight": {YEAR: weight}, + "hbai_household_net_income_before_reset_to_zero": {YEAR: income}, + "poverty_line_bhc": {YEAR: line}, + "poverty_line_ahc": {YEAR: line}, + } + return {"people": people, "benunits": families, "households": households} + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_person_mapped_weighted_mean_differs_from_household_mean(basis): + sim = Microsimulation(situation=situation([1, 3], [2, 1], [100, 100], [50, 0])) + variable = f"squared_poverty_gap_{basis}" + component = sim.calculate(variable, YEAR, map_to="person") + np.testing.assert_allclose(component, [0.25, 1, 1, 1]) + np.testing.assert_allclose(component.weights, [2, 1, 1, 1]) + assert component.mean(skipna=False) == pytest.approx(0.7) + assert sim.calculate(variable, YEAR).mean(skipna=False) == pytest.approx(0.5) + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_undefined_household_is_not_dropped_from_person_mean(basis): + sim = Microsimulation(situation=situation([1, 3], [2, 1], [100, 0], [50, 0])) + with np.errstate(all="raise"): + component = sim.calculate(f"squared_poverty_gap_{basis}", YEAR, map_to="person") + assert component.iloc[0] == 0.25 + assert np.isnan(component.iloc[1:]).all() + assert np.isnan(component.mean(skipna=False)) + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_mixed_positive_zero_negative_lines_do_not_emit_division_warnings(basis): + sim = Simulation( + situation=situation( + [1, 1, 1, 1], [1, 1, 1, 1], [100, 0, -100, 200], [50, 0, 0, 100] + ) + ) + with np.errstate(all="raise"): + component = sim.calculate(f"squared_poverty_gap_{basis}", YEAR) + np.testing.assert_allclose(component, [0.25, np.nan, np.nan, 0.25], equal_nan=True) + + +def test_components_are_annual_dimensionless_household_variables_without_alias(): + system = CountryTaxBenefitSystem() + for basis in ["bhc", "ahc"]: + variable = system.get_variable(f"squared_poverty_gap_{basis}") + assert variable is not None + assert variable.entity.key == "household" + assert str(variable.definition_period) == "year" + assert variable.unit == "/1" + assert "squared_poverty_gap" not in system.variables diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py new file mode 100644 index 0000000000..4ca3c850fd --- /dev/null +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +import numpy as np + + +class squared_poverty_gap_ahc(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "/1" + label = "Normalized squared poverty gap, after housing costs" + documentation = ( + "Dimensionless household FGT2 component: the square of the existing " + "after housing costs poverty gap divided by its matching poverty line. " + "Uses the existing HBAI income basis; a nonpositive line is undefined " + "and returns NaN." + ) + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality" + + def formula(household, period, parameters): + gap = household("poverty_gap_ahc", period) + line = household("poverty_line_ahc", period) + normalized_gap = np.divide( + gap, + line, + out=np.full_like(gap, np.nan, dtype=float), + where=line > 0, + ) + return np.square(normalized_gap) diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py new file mode 100644 index 0000000000..38d79bbce6 --- /dev/null +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +import numpy as np + + +class squared_poverty_gap_bhc(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "/1" + label = "Normalized squared poverty gap, before housing costs" + documentation = ( + "Dimensionless household FGT2 component: the square of the existing " + "before housing costs poverty gap divided by its matching poverty line. " + "Uses the existing HBAI income basis; a nonpositive line is undefined " + "and returns NaN." + ) + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality" + + def formula(household, period, parameters): + gap = household("poverty_gap_bhc", period) + line = household("poverty_line_bhc", period) + normalized_gap = np.divide( + gap, + line, + out=np.full_like(gap, np.nan, dtype=float), + where=line > 0, + ) + return np.square(normalized_gap) From 3576003023eb9b3b6e12229dc080f93caada2f9d 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 12:31:36 +0100 Subject: [PATCH 078/224] Fix poverty measure source section links --- docs/book/validation/hbai.md | 2 +- .../variables/household/income/squared_poverty_gap_ahc.py | 2 +- .../variables/household/income/squared_poverty_gap_bhc.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 7644c80e41..f9ccfc6609 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -64,7 +64,7 @@ $$ For example, income at or above the line gives zero, income halfway to the line gives 0.25, and zero income gives one. Scaling income and its line by the same positive factor leaves the component unchanged. These are normalized components, not GBP-squared poverty gaps or sums. -The statistical definition follows the [World Bank PIP methodology, section 4.1](https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality). The variables retain the UK HBAI basis described above: negative BHC income is reset to zero, while AHC income can be negative. The AHC component can therefore exceed one. PIP's own negative-welfare preprocessing is not reproduced here, and these components are not an official DWP severity estimate. +The statistical definition follows the [World Bank PIP methodology, section 4.1](https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty). The variables retain the UK HBAI basis described above: negative BHC income is reset to zero, while AHC income can be negative. The AHC component can therefore exceed one. PIP's own negative-welfare preprocessing is not reproduced here, and these components are not an official DWP severity estimate. For a person-based population index, map each household's component to every person and take the existing survey-weighted **mean**, not a sum or a household-weighted mean. Given an already configured `Microsimulation` named `sim`: diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py index 4ca3c850fd..84b8df03a4 100644 --- a/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py @@ -14,7 +14,7 @@ class squared_poverty_gap_ahc(Variable): "Uses the existing HBAI income basis; a nonpositive line is undefined " "and returns NaN." ) - reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality" + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty" def formula(household, period, parameters): gap = household("poverty_gap_ahc", period) diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py index 38d79bbce6..b13b7b2579 100644 --- a/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py @@ -14,7 +14,7 @@ class squared_poverty_gap_bhc(Variable): "Uses the existing HBAI income basis; a nonpositive line is undefined " "and returns NaN." ) - reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetary-poverty-and-inequality" + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty" def formula(household, period, parameters): gap = household("poverty_gap_bhc", period) From f91ca3588894624a3e77a03133b5a5a8d83e8893 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 13:36:58 +0100 Subject: [PATCH 079/224] Fix Scottish CTR severe-disability family test --- ...x-2295-scottish-ctr-nondependants.fixed.md | 1 + ...evere_disability_premium_young_person.yaml | 167 ++++++++++++++++++ ..._tax_reduction_severe_disability_family.py | 141 +++++++++++++++ ...council_tax_reduction_applicable_amount.py | 6 +- ...tax_reduction_severe_disability_premium.py | 30 ++++ ...tax_reduction_severe_disability_premium.py | 59 +++++++ .../demographic/severe_disability_premium.py | 43 +---- ...ility_premium_before_non_dependant_test.py | 63 +++++++ 8 files changed, 468 insertions(+), 42 deletions(-) create mode 100644 changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py create mode 100644 policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py diff --git a/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md b/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md new file mode 100644 index 0000000000..2850e8d98a --- /dev/null +++ b/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md @@ -0,0 +1 @@ +Use Scotland's working-age Council Tax Reduction young-person definition for the severe disability premium's non-dependant test within the applicant's benefit unit, retaining existing looked-after and other-benefit-unit treatment. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml new file mode 100644 index 0000000000..8e67b31977 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml @@ -0,0 +1,167 @@ +# SSI2021/249 regs6,8(2)(a),Sch1para13. Existing2026 model controls: +# allowance95.55*52=4968.60; severe86.05*52=4474.60; withdrawal20%. +# PIP qualifies SDP but generic disability flag is separately supplied/defaultfalse. +# Pension12570:no tax; PIP disregarded; Child Benefit claim declined. +# CTR=2000-20%*(12570-allowance):479.72 or1374.64. +# Annual UC terminal-date input represents unavailable assessment-period detail. +# Reverse case supplies CB family status: input-contract composition, +# not a naturally derived eligibility pair or full statutory rate audit. +# Shared HB premium component and IS applicable amount are protected below. +# This household has no continuing HB claim, so its gated HB applicable +# amount is0; it is not used as a positive HB-payment regression oracle. +- name: 'Terminal-date input contract: CB young person after UC terminal date removes + Scottish CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 479.72 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 4968.6 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 0 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: Before terminal date both definitions retain Scottish CTR premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: 'Supplied-status contract: UC young person outside CB family restores Scottish + CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 0 + benefits_premiums: 0 + income_support_applicable_amount: 4968.6 diff --git a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py new file mode 100644 index 0000000000..4334138476 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -0,0 +1,141 @@ +"""CTR family-mask input contracts, separate from derived eligibility tests. + +SSI 2021/249 regs 6 and 8(2)(a), Sch 1 para 13 retain an applicant's +UC young person in their family. These finite grids deliberately supply the +two young-person statuses to exercise both directions independently of the +upstream eligibility rules. The policy YAML uses school/course inputs and +the annual terminal-date input, plus actual PIP, pension and council tax. + +Looked-after and other-benefit-unit treatment remains the existing model +baseline, not a certification of reg 8(2)(b) and its paragraph (3) overrides. +""" + +from itertools import product + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): + people, benunits, households = {}, {}, {} + for i, row in enumerate(rows): + applicant, student = f"a{i}", f"s{i}" + people[applicant] = { + "age": applicant_age, + "is_claimant_or_partner": True, + "pip_dl_category": "ENHANCED", + } + people[student] = { + "age": row.get("age", 19), + "is_claimant_or_partner": False, + "is_child_or_qualifying_young_person_for_child_benefit": row["cb"], + "is_qualifying_young_person_for_universal_credit": row["uc"], + "is_looked_after_by_local_authority": row.get("looked_after", False), + "is_blind": row.get("blind", False), + "pip_dl_category": "STANDARD" if row.get("benefit", False) else "NONE", + } + members = [applicant, student] + benunits[f"b{i}"] = { + "members": [applicant] if row.get("other_bu", False) else members, + "would_claim_uc": False, + "would_claim_IS": False, + "would_claim_pc": False, + } + if row.get("other_bu", False): + benunits[f"other{i}"] = {"members": [student]} + households[f"h{i}"] = {"members": members, "country": country} + situation = {"people": people, "benunits": benunits, "households": households} + # A situation's default period is the model default, not necessarily year. + for group in situation.values(): + for values in group.values(): + for name, value in list(values.items()): + if name != "members": + values[name] = {year: value} + return Simulation(situation=situation), [f"b{i}" for i in range(len(rows))] + + +def values(sim, variable, names, year=2026): + result = sim.calculate(variable, year) + ids = list(sim.populations["benunit"].ids) + return np.array([result[ids.index(name)] for name in names]) + + +@pytest.mark.parametrize( + "country,year,applicant_age,scottish_working_age", + [ + ("SCOTLAND", 2026, 45, True), + ("SCOTLAND", 2021, 45, False), + ("SCOTLAND", 2026, 70, False), + ("ENGLAND", 2026, 45, False), + ("WALES", 2026, 45, False), + ("NORTHERN_IRELAND", 2026, 45, False), + ], +) +def test_own_family_status_contract_by_applicant_scheme( + country, year, applicant_age, scottish_working_age +): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows, country, year, applicant_age) + expected = [ + not ( + row["uc"] if scottish_working_age and not row["looked_after"] else row["cb"] + ) + for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + year, + ).tolist() + == expected + ) + # HB and IS retain the original Child Benefit family classification. + assert values( + sim, "has_non_dependant_for_severe_disability_premium", names, year + ).tolist() == [not row["cb"] for row in rows] + + +def test_age_boundary_and_blind_or_benefit_exceptions(): + rows = [ + dict(age=age, cb=True, uc=False, blind=blind, benefit=benefit) + for age, blind, benefit in product( + [17.999, 18, 19, 20], [False, True], [False, True] + ) + ] + sim, names = input_grid(rows) + expected = [ + row["age"] >= 18 and not row["blind"] and not row["benefit"] for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).tolist() + == expected + ) + # The shared gate still disregards these own-BU Child Benefit young people. + assert not values( + sim, "has_non_dependant_for_severe_disability_premium", names + ).any() + + +def test_other_benefit_units_keep_existing_treatment(): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after, other_bu=True) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows) + assert values(sim, "has_non_dependant_for_severe_disability_premium", names).all() + assert values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).all() diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index a63c5c4032..01e71f0cd4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -57,6 +57,10 @@ def formula(benunit, period, parameters): premiums = where( pensioner, benunit("pension_age_benefits_premiums", period), - benunit("working_age_benefits_premiums", period), + benunit("working_age_benefits_premiums", period) + + ( + benunit("council_tax_reduction_severe_disability_premium", period) + - benunit("severe_disability_premium", period) + ), ) return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py new file mode 100644 index 0000000000..ba30175f1e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_severe_disability_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR severe disability premium" + documentation = ( + "The shared legacy premium's amount before its non-dependant test, " + "with the Council Tax Reduction-specific family test applied. This " + "allows either direction of a difference between the Universal Credit " + "and Child Benefit young-person definitions. The test retains the " + "existing classification for looked-after residents and other benefit " + "units; see has_non_dependant_for_council_tax_reduction_severe_disability_premium. " + "Pension-age Council Tax Reduction continues to use its separate premium." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + amount = benunit("severe_disability_premium_before_non_dependant_test", period) + no_non_dependant = ~benunit( + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + period, + ) + return amount * no_non_dependant diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py new file mode 100644 index 0000000000..60c1037520 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class has_non_dependant_for_council_tax_reduction_severe_disability_premium(Variable): + value_type = bool + entity = BenUnit + label = "Has a non-dependant who blocks the CTR severe disability premium" + documentation = ( + "Uses the applicant family's Council Tax Reduction young-person definition " + "for non-looked-after members of its own benefit unit in Scotland's " + "working-age scheme. The applicant's own pensioner status and the dated " + "young-person parameter select that scheme. All other schemes retain " + "the shared Housing Benefit/Income Support test. Blind residents and " + "qualifying-benefit recipients remain disregarded. Looked-after members " + "retain the exact existing Child Benefit family classification, and " + "other benefit units retain the existing treatment. Reg 8(2)(b), subject " + "to reg 8(3), also excludes some young people outside the applicant's " + "responsibility; the model lacks complete tenancy and responsibility " + "relations for those looked-after and other-benefit-unit cases. They " + "remain outside this partial correction. Other limitations of " + "has_non_dependant_for_severe_disability_premium also remain." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + counted = ( + (person("age", period) >= p.severe_non_dependant_age) + & ~person("receives_severe_disability_premium_qualifying_benefit", period) + & ~person("is_blind", period) + ) + child_or_young_person = where( + person("is_looked_after_by_local_authority", period), + person("is_child_or_qualifying_young_person_for_child_benefit", period), + person("is_child_or_young_person_for_council_tax_reduction", period), + ) + family = person("is_claimant_or_partner", period) | child_or_young_person + in_household = benunit.max(person.household.sum(counted)) + non_dependant = in_household > benunit.sum(counted & family) + scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.working_age + scottish_working_age = ( + (benunit.household("country", period) == Country.SCOTLAND) + & ~benunit("council_tax_reduction_pensioner", period) + & scotland.young_person_follows_universal_credit + ) + return where( + scottish_working_age, + non_dependant, + benunit("has_non_dependant_for_severe_disability_premium", period), + ) diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 0518743097..d748f85fb4 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -50,47 +50,8 @@ class severe_disability_premium(Variable): unit = GBP def formula(benunit, period, parameters): - # Whom a carer benefit is paid for caring for is attributed by - # is_cared_for_by_carer_benefit_recipient, which the Pension Credit - # severe disability addition shares. The hospital rules that keep a - # patient treated as in receipt (and then pay the single rate) are not - # modelled. - p = parameters(period).gov.dwp.disability_premia - person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - qualifies = person( - "receives_severe_disability_premium_qualifying_benefit", period - ) - qualifying_member = claimant_or_partner & qualifies - qualifying = benunit.sum(qualifying_member) - partner_treated_as_absent = benunit.any( - claimant_or_partner & ~qualifies & person("is_blind", period) - ) - cared_for = benunit.sum( - qualifying_member - & person("is_cared_for_by_carer_benefit_recipient", period) - ) - # Number of single rates payable: two for a couple who both qualify - # with no carer paid for either, one when a carer is paid for only - # one of them, and one for a single claimant (or a couple whose - # other partner is blind) with no carer paid for them. - couple = benunit("is_couple", period) - single_condition = where( - couple, - (qualifying == 1) & partner_treated_as_absent, - qualifying >= 1, - ) & (cared_for == 0) - rates = where( - couple & (qualifying >= 2), - max_(2 - cared_for, 0), - single_condition, - ) - weekly_amount = select( - [rates >= 2, rates == 1], - [p.severe_couple, p.severe_single], - default=0, - ) + amount = benunit("severe_disability_premium_before_non_dependant_test", period) no_non_dependant = ~benunit( "has_non_dependant_for_severe_disability_premium", period ) - return weekly_amount * WEEKS_IN_YEAR * no_non_dependant + return amount * no_non_dependant diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py new file mode 100644 index 0000000000..eac30436c8 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py @@ -0,0 +1,63 @@ +from policyengine_uk.model_api import * + + +class severe_disability_premium_before_non_dependant_test(Variable): + value_type = float + entity = BenUnit + label = "Severe disability premium before the non-dependant test" + documentation = ( + "The shared legacy premium's annual amount after its qualifying-benefit, " + "partner and carer conditions, before its non-dependant condition. " + "This is a component, not a paid award. It retains the assumptions and " + "limitations documented by severe_disability_premium. Council Tax " + "Reduction can apply its own family definition to this amount." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", + ) + + def formula(benunit, period, parameters): + # Whom a carer benefit is paid for caring for is attributed by + # is_cared_for_by_carer_benefit_recipient, which the Pension Credit + # severe disability addition shares. The hospital rules that keep a + # patient treated as in receipt (and then pay the single rate) are not + # modelled. + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifies = person( + "receives_severe_disability_premium_qualifying_benefit", period + ) + qualifying_member = claimant_or_partner & qualifies + qualifying = benunit.sum(qualifying_member) + partner_treated_as_absent = benunit.any( + claimant_or_partner & ~qualifies & person("is_blind", period) + ) + cared_for = benunit.sum( + qualifying_member + & person("is_cared_for_by_carer_benefit_recipient", period) + ) + # Number of single rates payable: two for a couple who both qualify + # with no carer paid for either, one when a carer is paid for only + # one of them, and one for a single claimant (or a couple whose + # other partner is blind) with no carer paid for them. + couple = benunit("is_couple", period) + single_condition = where( + couple, + (qualifying == 1) & partner_treated_as_absent, + qualifying >= 1, + ) & (cared_for == 0) + rates = where( + couple & (qualifying >= 2), + max_(2 - cared_for, 0), + single_condition, + ) + weekly_amount = select( + [rates >= 2, rates == 1], + [p.severe_couple, p.severe_single], + default=0, + ) + return weekly_amount * WEEKS_IN_YEAR From e4af43e3bb2d67b4ea889cf82e0329449db873ac 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 14:21:24 +0100 Subject: [PATCH 080/224] Count legacy incapacity benefits in UC unearned income --- changelog.d/2424.fixed.md | 1 + .../gov/dwp/disability-legacy-benefits.md | 32 +++ .../income_definitions/unearned.yaml | 27 ++- .../income/uc_incapacity_income.yaml | 200 ++++++++++++++++++ .../tests/test_uc_incapacity_income.py | 116 ++++++++++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2424.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml create mode 100644 policyengine_uk/tests/test_uc_incapacity_income.py diff --git a/changelog.d/2424.fixed.md b/changelog.d/2424.fixed.md new file mode 100644 index 0000000000..3883ab7bb4 --- /dev/null +++ b/changelog.d/2424.fixed.md @@ -0,0 +1 @@ +Count Incapacity Benefit and Severe Disablement Allowance as Universal Credit unearned income from the GB transitional commencement, preserving earlier history and the existing annual-model limitations. diff --git a/docs/book/programs/gov/dwp/disability-legacy-benefits.md b/docs/book/programs/gov/dwp/disability-legacy-benefits.md index 331a5c133e..dc63b96fca 100644 --- a/docs/book/programs/gov/dwp/disability-legacy-benefits.md +++ b/docs/book/programs/gov/dwp/disability-legacy-benefits.md @@ -65,6 +65,38 @@ addition. This is a deliberate simplification: SDA was closed to new claims in 2001 and the remaining caseload is small and old enough that nearly all recipients qualify for the highest age-related addition. +## Universal Credit income treatment + +Universal Credit counts a claimant's or assessed partner's Incapacity Benefit +(`incapacity_benefit`) and modeled SDA award (`sda`) as unearned income, reducing +the award pound for pound up to the maximum UC award. A positive `sda_reported` +input therefore counts the modeled maximum SDA amount, not the reported cash +amount. Other benefit units' income and same-unit nonclaimants' income remain +outside the existing assessed-claimant aggregation. + +[GB transitional regulation 25(1)](https://www.legislation.gov.uk/uksi/2014/1230/regulation/25) +adds both benefits from 16 June 2014. The model preserves the original 2013 +income list and adds them at that date, including in the later list containing +Carer Support Payment. Annual calculations from 2015 read the 30 April value. +The first, part-year 2014 annual treatment remains unresolved +([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)); the dated +entry does not provide assessment-period calculations or proration. The +[NI transitional counterpart](https://www.legislation.gov.uk/nisr/2016/226/regulation/26) +supports current commenced NI claims, not an assertion of NI commencement in +2014. + +Carer's Allowance enters UC income after the existing overlapping-benefit +adjustment. Within the allowance's positive residual, an extra pound of annual +IB replaces a pound of Carer's Allowance, leaving their combined UC income +unchanged. Once the allowance is zero, further IB increases UC income. This +annual netting does not represent benefit spells at different weekly rates. + +The focused household tests retain live UC eligibility, disability/carer +elements and final awards. Legacy receipt and disability-assessment inputs are +assumptions, not a re-derivation of IB/SDA entitlement. The care-hours interface +does not identify the person cared for or check their qualifying benefit. No +private-data impact or Axiom parity has been measured. + ## References - DLA primary statute: [Social Security Contributions and Benefits Act 1992 s. 71](https://www.legislation.gov.uk/ukpga/1992/4/section/71). diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index ff0d282452..f49cc9ef8c 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -19,7 +19,13 @@ description: > reads from `self_employment_income`. Whether other letting can be a trade is unsettled: DWP staff guidance treats a self-employed landlord's rent as self-employed income, while letting is not a trade for tax, and the model - does not identify such landlords. + does not identify such landlords. Incapacity Benefit and Severe Disablement + Allowance count from 16 June 2014 under the GB transitional regulations; + the NI transitional counterpart supports them in commenced NI claims. + The 2013 list is preserved. Annual parameters from 2015 use the value at + 30 April: the June entry does not resolve the part-year 2014 award or date + Northern Ireland commencement. SDA uses the existing maximum-rate proxy, + and Carer's Allowance counts after its overlapping-benefit adjustment. metadata: economy: false label: Universal Credit unearned income sources @@ -29,6 +35,12 @@ metadata: reference: - title: The Universal Credit Regulations 2013 reg. 66(1) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 + - title: The Universal Credit (Transitional Provisions) Regulations 2014 reg. 25(1) (Incapacity Benefit and Severe Disablement Allowance) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/25 + - title: S.I. 2014/1230 reg. 1 as made (commencement on 16 June 2014) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/1/made + - title: The Universal Credit (Transitional Provisions) Regulations (Northern Ireland) 2016 reg. 26(1) + href: https://www.legislation.gov.uk/nisr/2016/226/regulation/26 - title: The Universal Credit Regulations 2013 reg. 67 (retirement pension income) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/67 - title: State Pension Credit Act 2002 s. 16(1)(za), (a) (State Pension counts as retirement pension income) @@ -63,6 +75,17 @@ values: - private_pension_income - state_pension - uc_tariff_income + 2014-06-16: + - carers_allowance + - jsa_contrib + - esa_contrib + - maternity_allowance + - iidb + - private_pension_income + - state_pension + - uc_tariff_income + - incapacity_benefit + - sda 2023-11-19: - carers_allowance - uc_unearned_carer_support_payment @@ -73,3 +96,5 @@ values: - private_pension_income - state_pension - uc_tariff_income + - incapacity_benefit + - sda diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml new file mode 100644 index 0000000000..8ed08fb08f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml @@ -0,0 +1,200 @@ +# Annual public-input controls, not an audit of legacy entitlement or rates. +# Ages 60/58 in 2026 permit legacy IB/SDA histories; receipt remains an input. +# CA annual netting represents constant year-round amounts. The care-hours +# interface does not attribute care to a recipient: the represented PIP recipient +# documents the qualifying-care premise without certifying that missing link. +# UC eligibility, components, income reduction and final awards remain live. + +- name: IB reduces a working-age claimant's positive final UC award + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + incapacity_benefit_reported: 1_000 + country: ENGLAND + output: + universal_credit: 424.90 * 12 - 1_000 + incapacity_benefit: 1_000 + is_uc_eligible: true + uc_maximum_amount: 424.90 * 12 + uc_unearned_income: 1_000 + uc_income_reduction: 1_000 + +- name: No legacy income preserves a positive final UC award + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + country: ENGLAND + output: + universal_credit: 424.90 * 12 + uc_unearned_income: 0 + +- name: IB 0 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 0 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [3115.48, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [4495.40, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [4495.40, 0] + uc_income_reduction: [4495.40, 0] + +- name: IB 1000 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 1000 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [3115.48, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [3495.40, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [4495.40, 0] + uc_income_reduction: [4495.40, 0] + +- name: IB 5000 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 5000 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [2610.88, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [0.00, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [5000.00, 0] + uc_income_reduction: [5000.00, 0] + +# SDA is the existing maximum-rate proxy, not the reported cash amount. +# The public disability flag supplies the assessment premise; the calculator +# does not infer this flag from a disability benefit report as datasets do. +# Unchanged 2026 standard/health rates below are model controls, not a rate audit. +- name: SDA report 1 counts the full modeled award and live LCWRA element + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + sda_reported: 1 + is_disabled_for_benefits: true + partner: + age: 58 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + universal_credit: (666.97 + 217.26) * 12 - 119.35 * 52 + sda: [6206.20, 0] + uc_limited_capability_for_WRA: [true, false] + uc_LCWRA_element: 217.26 * 12 + is_uc_eligible: true + uc_maximum_amount: (666.97 + 217.26) * 12 + uc_unearned_income: 119.35 * 52 + uc_income_reduction: 119.35 * 52 + +# SDA is the existing maximum-rate proxy, not the reported cash amount. +# The public disability flag supplies the assessment premise; the calculator +# does not infer this flag from a disability benefit report as datasets do. +# Unchanged 2026 standard/health rates below are model controls, not a rate audit. +- name: SDA report 100 counts the full modeled award and live LCWRA element + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + sda_reported: 100 + is_disabled_for_benefits: true + partner: + age: 58 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + universal_credit: (666.97 + 217.26) * 12 - 119.35 * 52 + sda: [6206.20, 0] + uc_limited_capability_for_WRA: [true, false] + uc_LCWRA_element: 217.26 * 12 + is_uc_eligible: true + uc_maximum_amount: (666.97 + 217.26) * 12 + uc_unearned_income: 119.35 * 52 + uc_income_reduction: 119.35 * 52 + +- name: A current Northern Ireland claimant's IB reduces final UC + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + incapacity_benefit_reported: 1_000 + country: NORTHERN_IRELAND + output: + universal_credit: 424.90 * 12 - 1_000 + is_uc_eligible: true + uc_unearned_income: 1_000 diff --git a/policyengine_uk/tests/test_uc_incapacity_income.py b/policyengine_uk/tests/test_uc_incapacity_income.py new file mode 100644 index 0000000000..3594a20d12 --- /dev/null +++ b/policyengine_uk/tests/test_uc_incapacity_income.py @@ -0,0 +1,116 @@ +"""Income classification, role boundaries and dated annual parameter controls.""" + +from pathlib import Path + +import pytest +import policyengine_uk +from policyengine_core.parameters import ParameterNode +from policyengine_uk import CountryTaxBenefitSystem, Simulation + + +ORIGINAL = [ + "carers_allowance", + "jsa_contrib", + "esa_contrib", + "maternity_allowance", + "iidb", + "private_pension_income", + "state_pension", + "uc_tariff_income", +] + + +def _income(parameters): + return parameters.gov.dwp.universal_credit.means_test.income_definitions.unearned + + +@pytest.mark.parametrize( + "on, included, csp", + [ + ("2013-04-29", False, False), + ("2014-06-15", False, False), + ("2014-06-16", True, False), + ("2023-11-18", True, False), + ("2023-11-19", True, True), + ], +) +def test_raw_statutory_income_list_boundaries(on, included, csp): + # Raw statutory dates: this does not certify a 2014 annual UC award. + raw = ParameterNode( + directory_path=str(Path(policyengine_uk.__file__).parent / "parameters") + ) + actual = list(_income(raw)(on)) + expected = ORIGINAL + (["incapacity_benefit", "sda"] if included else []) + if csp: + expected.append("uc_unearned_carer_support_payment") + assert len(actual) == len(set(actual)) == len(expected) + assert set(actual) == set(expected) + if not included: + assert actual == ORIGINAL + + +@pytest.mark.parametrize( + "year, csp", [(2015, False), (2023, False), (2024, True), (2026, True)] +) +def test_processed_annual_list_keeps_legacy_income(year, csp): + actual = list(_income(CountryTaxBenefitSystem().parameters)(str(year))) + expected = ORIGINAL + ["incapacity_benefit", "sda"] + if csp: + expected.append("uc_unearned_carer_support_payment") + assert len(actual) == len(set(actual)) == len(expected) + assert set(actual) == set(expected) + + +@pytest.mark.parametrize( + "reported, amount", + [("incapacity_benefit_reported", 1000), ("sda_reported", 6206.20)], +) +def test_assessed_partner_income_counts_but_other_unit_income_does_not( + reported, amount +): + sim = Simulation( + situation={ + "people": { + "claimant": {"age": {2026: 60}}, + "partner": {"age": {2026: 58}, reported: {2026: 1000}}, + "other": {"age": {2026: 60}, reported: {2026: 2000}}, + }, + "benunits": { + "couple": {"members": ["claimant", "partner"]}, + "other": {"members": ["other"]}, + }, + "households": { + "household": { + "members": ["claimant", "partner", "other"], + "country": {2026: "ENGLAND"}, + } + }, + } + ) + assert sim.calculate("is_uc_assessed_claimant", 2026).tolist() == [True, True, True] + assert sim.calculate("uc_unearned_income", 2026).tolist() == pytest.approx( + [amount, amount if reported == "sda_reported" else 2000] + ) + + +@pytest.mark.parametrize("reported", ["incapacity_benefit_reported", "sda_reported"]) +def test_same_unit_nonclaimant_report_is_excluded_input_contract(reported): + # Not a lawful legacy award to a child: exercises the supplied-input role + # contract without certifying legacy entitlement or child LCWRA treatment. + sim = Simulation( + situation={ + "people": { + "parent": {"age": {2026: 60}}, + "child": {"age": {2026: 17}, reported: {2026: 1000}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": { + "household": { + "members": ["parent", "child"], + "country": {2026: "ENGLAND"}, + } + }, + } + ) + assert sim.calculate("is_uc_assessed_claimant", 2026).tolist() == [True, False] + assert sim.calculate("uc_unearned_income", 2026).tolist() == [0] From e8bb9f37fd1ef1476d60a92bdcba7481117ea8cd 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 16:24:39 +0100 Subject: [PATCH 081/224] Fix dated reform replay inside construction wrappers --- changelog.d/2480.fixed.md | 1 + docs/book/usage/scenarios.md | 7 +- policyengine_uk/simulation.py | 11 +- .../test_construction_reform_wrappers.py | 348 ++++++++++++++++++ policyengine_uk/utils/scenario.py | 99 ++++- 5 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2480.fixed.md create mode 100644 policyengine_uk/tests/test_construction_reform_wrappers.py diff --git a/changelog.d/2480.fixed.md b/changelog.d/2480.fixed.md new file mode 100644 index 0000000000..6aa350961f --- /dev/null +++ b/changelog.d/2480.fixed.md @@ -0,0 +1 @@ +Fix stale construction-time reform wrappers after foreign writes or direct edits while preserving copied scenarios and constructor readings; document mixed-composite replay, indexed-threshold construction and the existing rejection of built overrides whose keys contain no 1 January annual value. diff --git a/docs/book/usage/scenarios.md b/docs/book/usage/scenarios.md index 20b40689b3..706772f6d5 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -424,7 +424,12 @@ A dataset's later years are projected from its first by the economic assumptions All of this applies when the scenario or reform is passed to `Simulation(...)`. `Scenario.apply(simulation)` on a simulation that is already built edits its annualised values instead, as it always has: a `"2027-04-06.2028-04-05"` key there overrides those dates of the fiscal-year values, so it does not set fiscal year 2027, and the model logs a warning saying so. Pass the scenario when constructing the simulation for dated measures. The modifier returned by `Scenario.from_reform` behaves the same way on a built simulation for changes that are no longer effective, and raises if an external override key contains no 1 January annual value. -A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites always replay in their requested order; this can append duplicate override-log entries. An external key that contains no 1 January annual value raises with its path and key. + +During construction, the already-written statutory changes of an original dated reform keep the constructor's reading while their parameter paths remain untouched by foreign writes or direct parameter edits. This includes a value-equal reform called by a wrapper or a deep-copied scenario. The original modifiers still apply their single-day leftovers in order. If a user modifier edits those statutory paths and then requests the same reform, that request instead overrides the built, annualised values. An external composite replays its complete requested sequence; its children cannot skip a write merely because they equal an original constructor reform. Edits before data load remain foreign when a later modifier runs after data load. This boundary concerns parameter writes, not the different award values that Universal Credit rebalancing intentionally stores. + +A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites on a built simulation always replay in their requested order; this can append duplicate override-log entries and change the copy's original construction reading. For example, a dict with a basic-rate window `"2026-06-01.2027-03-31": 0.31` and a VAT single-day key `"2027-01-01": 0.25` gives the 2027 basic rate its unchanged 20% construction value. Replaying its modifier on a clone overrides 1 January 2027 with 31%; the original simulation stays unchanged. Relative `Reform` classes and custom modifiers still run when called on a copy; a modifier that multiplies a parameter by 1.1 applies that multiplication to the copy's current value. When changing policy parameters on a branch, request `get_branch("policy", clone_system=True)`; the default branch shares its tax-benefit system with its parent. + +Dated measures on indexed thresholds also need `Simulation(scenario=...)` or `reform=...` for the statutory projection and indexation resume described above. Applying them to a baseline-built simulation overrides only its already annualised values; it does not recalculate the indexed path after the override ends. An external key that contains no 1 January annual value raises with its path and key, including `"2027-04-06"` and `"2027-04-06.2027-12-31"`. ### Creating complex scenarios with simulation modifiers diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 385be1f50b..56d5b35eae 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -30,7 +30,7 @@ UKMultiYearDataset, UKSingleYearDataset, ) -from policyengine_uk.utils.scenario import Scenario +from policyengine_uk.utils.scenario import ConstructionReformState, Scenario from policyengine_uk.data.economic_assumptions import ( extend_single_year_dataset, reset_growthfactor_uprating, @@ -258,6 +258,7 @@ def __init__( # of this scenario's single-day leftovers while rejecting unreadable # overrides from a user modifier's separate from_reform call. self._building_dated_reforms = scenario.dated_reforms if scenario else None + self._construction_reform_state = None self.branch_name = "default" self.invalidated_caches = set() @@ -295,8 +296,10 @@ def __init__( ) if before_data_load is not None: self.apply_scenario_parameters(before_data_load) + self._construction_reform_state = ConstructionReformState(self) if before_data_load.simulation_modifier is not None: before_data_load.simulation_modifier(self) + self._construction_reform_state.observe_user_edits(self) elif after_parameters_before_data: self.apply_scenario_parameters(after_data_load) self._refresh_capital_gains_projection() @@ -325,6 +328,8 @@ def __init__( # (including single-day reform-dict keys) leave the data as built. if after_data_load is not None and not after_parameters_before_data: self.apply_scenario_parameters(after_data_load) + if self._construction_reform_state is None and self._building_dated_reforms: + self._construction_reform_state = ConstructionReformState(self) # Universal Credit reform (July 2025). Needs closer integration in the baseline, # but adding here for ease of toggling on/off via the 'active' parameter. @@ -376,10 +381,13 @@ def __init__( self.baseline._cgt_warning_enabled = False self.baseline._defer_uc_health_warning = False self.baseline._building_dated_reforms = None + self.baseline._construction_reform_state = None self.calculated_periods = [] if after_data_load is not None: + if self._construction_reform_state is not None: + self._construction_reform_state.refresh_framework(self) if after_data_load.simulation_modifier is not None: after_data_load.simulation_modifier(self) self._refresh_capital_gains_projection() @@ -389,6 +397,7 @@ def __init__( self._defer_uc_health_warning = False self._building_dated_reforms = None + self._construction_reform_state = None warn_shadowed_lcwra_reform(self) def _refresh_capital_gains_projection(self) -> None: diff --git a/policyengine_uk/tests/test_construction_reform_wrappers.py b/policyengine_uk/tests/test_construction_reform_wrappers.py new file mode 100644 index 0000000000..437b633165 --- /dev/null +++ b/policyengine_uk/tests/test_construction_reform_wrappers.py @@ -0,0 +1,348 @@ +"""Construction-owned reform wrappers retain their reading until edited.""" + +import copy +from decimal import Decimal, ROUND_CEILING + +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.scenarios.uc_reform import UCHealthElementOverrideWarning +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +BASIC_RATE = "gov.hmrc.income_tax.rates.uk[0].rate" +YEARS = (2026, 2027, 2028) +SITUATION = { + "people": {"person1": {"age": {year: 40 for year in YEARS}}}, + "benunits": {"benunit": {"members": ["person1"]}}, + "households": {"household": {"members": ["person1"]}}, +} +A = {BASIC_RATE: {"2027-01-01.2027-12-31": 0.31}} +L = {BASIC_RATE: {"2027-01-01": 0.35}} +VAT = "gov.hmrc.vat.standard_rate" +PA = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +CPI = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +STATUTORY_CPI = "gov.economic_assumptions.yoy_growth.september_cpi_uprating" +FISCAL_A = {BASIC_RATE: {"2026-04-06.2027-04-05": 0.31}} + + +def parameter(simulation, path, date): + return simulation.tax_benefit_system.parameters.get_child(path)(date) + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("edit", ["legacy", "direct"]) +@pytest.mark.parametrize("composite", [False, True]) +def test_equal_constructor_wrapper_after_edit_matches_apply( + before_data_load, edit, composite +): + def build(use_apply): + def wrapper(simulation): + if edit == "legacy": + Scenario.from_reform(L).apply(simulation) + else: + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + requested = Scenario.from_reform((L, A) if composite else A) + if use_apply: + requested.apply(simulation) + else: + requested.simulation_modifier(simulation) + + own = Scenario.from_reform(A) + own.applied_before_data_load = before_data_load + return Simulation( + situation=SITUATION, + scenario=own + + Scenario( + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ), + ) + + direct, applied = build(False), build(True) + assert parameter(applied, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(direct, BASIC_RATE, "2027-01-01") == 0.31 + for date in ("2026-12-31", "2027-01-01", "2027-07-01", "2027-12-31", "2028-01-01"): + assert parameter(direct, BASIC_RATE, date) == parameter( + applied, BASIC_RATE, date + ) + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +@pytest.mark.parametrize("reform_class", [False, True]) +def test_disjoint_composer_member_wrappers_keep_constructor_reading( + before_data_load, deepcopy, reform_class +): + # The acquired scenario_composition/v1 contract: all dated changes first, + # followed by every member's modifier, including fresh equal member calls. + reforms = (FISCAL_A, {VAT: {"2027-01-01.2027-12-31": 0.27}}) + members = [Scenario.from_reform(r) for r in reforms] + + def wrapper(simulation): + for reform in reforms: + request = Reform.from_dict(reform) if reform_class else reform + Scenario.from_reform(request).simulation_modifier(simulation) + + if reform_class: + members = [Scenario.from_reform(Reform.from_dict(r)) for r in reforms] + scenario = Scenario( + dated_reforms=[d for member in members for d in member.dated_reforms], + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.27 + assert simulation.tax_benefit_system.legacy_overrides == [] + assert simulation._construction_reform_state is None + assert simulation.baseline._construction_reform_state is None + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +def test_pristine_mixed_own_wrapper_keeps_legacy_and_statutory_readings( + before_data_load, deepcopy +): + changes = {**FISCAL_A, VAT: {"2027-01-01": 0.27}} + + def wrapper(simulation): + Scenario.from_reform(changes).simulation_modifier(simulation) + + scenario = Scenario.from_reform(changes) + scenario.applied_before_data_load = before_data_load + scenario += Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.27 + assert parameter(simulation, VAT, "2027-01-02") == 0.20 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_unrelated_direct_edit_does_not_replay_own_fiscal_change(before_data_load): + def wrapper(simulation): + simulation.tax_benefit_system.parameters.get_child(VAT).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(FISCAL_A) + scenario.applied_before_data_load = before_data_load + simulation = Simulation( + situation=SITUATION, + scenario=scenario + + Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ), + ) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.40 + assert simulation.tax_benefit_system.legacy_overrides == [] + + +@pytest.mark.parametrize("edit", ["legacy", "direct"]) +def test_foreign_edit_survives_phase_boundary_as_foreign(edit): + def before(simulation): + if edit == "legacy": + Scenario.from_reform(L).apply(simulation) + else: + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + + def after(simulation): + Scenario.from_reform(A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(A) + scenario.applied_before_data_load = True + scenario += Scenario(simulation_modifier=before, applied_before_data_load=True) + scenario += Scenario(simulation_modifier=after) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_direct_edit_outside_window_still_disables_construction_privilege( + before_data_load, +): + def wrapper(simulation): + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2030", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(FISCAL_A) + scenario.applied_before_data_load = before_data_load + simulation = Simulation( + situation=SITUATION, + scenario=scenario + + Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ), + ) + # A direct edit has no write-log record. Even an edit outside A's dates + # ends its path's construction privilege: A now takes the built reading. + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2030-01-01") == 0.40 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +def test_pristine_equal_member_keeps_constructor_later_member_order( + before_data_load, deepcopy +): + later = {BASIC_RATE: {"2027-01-01.2027-12-31": 0.35}} + + def wrapper(simulation): + Scenario.from_reform(A).simulation_modifier(simulation) + + scenario = Scenario.from_reform((A, later)) + scenario.applied_before_data_load = before_data_load + scenario += Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.35 + assert simulation.tax_benefit_system.legacy_overrides == [] + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_original_lcwra_shadow_is_not_a_foreign_parameter_edit(before_data_load): + situation = copy.deepcopy(SITUATION) + situation["people"]["person1"]["uc_limited_capability_for_WRA"] = { + year: True for year in YEARS + } + situation["benunits"]["benunit"].update( + would_claim_uc={year: True for year in YEARS}, + uc_receives_new_claimant_health_element={year: False for year in YEARS}, + ) + path = "gov.dwp.universal_credit.elements.disabled.amount" + changes = {path: {"2026-01-01.2028-12-31": 999.0}} + own = Scenario.from_reform(changes) + own.applied_before_data_load = before_data_load + with pytest.warns(UCHealthElementOverrideWarning): + control = Simulation(situation=situation, scenario=own) + + def wrapper(simulation): + Scenario.from_reform(changes).simulation_modifier(simulation) + + with pytest.warns(UCHealthElementOverrideWarning) as recorded: + simulation = Simulation( + situation=situation, + scenario=own + + Scenario( + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ), + ) + assert len(recorded) == 1 + assert parameter(simulation, path, "2027-01-01") == 999.0 + expected = control.calculate("uc_LCWRA_element", 2027) + assert np.all((expected > 0) & (expected < 999.0 * 12)) + np.testing.assert_array_equal( + simulation.calculate("uc_LCWRA_element", 2027), expected + ) + assert simulation.tax_benefit_system.legacy_overrides == [] + + +class ScaleAllowance(Reform): + def apply(self): + target = self.parameters.get_child(PA) + target.update(period="2027", value=target("2027") * 1.1) + + +def scale_allowance(simulation): + target = simulation.tax_benefit_system.parameters.get_child(PA) + target.update(period="2027", value=target("2027") * 1.1) + simulation.tax_benefit_system.reset_parameter_caches() + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +@pytest.mark.parametrize("representation", ["class", "modifier", "cpi_dict"]) +def test_relative_reforms_on_copies_match_explicit_apply(copy_kind, representation): + if representation == "class": + scenario = Scenario.from_reform(ScaleAllowance) + elif representation == "modifier": + scenario = Scenario(simulation_modifier=scale_allowance) + else: + scenario = Scenario.from_reform({CPI: {"2027": 0.08}}) + original = Simulation(situation=SITUATION) + original_allowance = parameter(original, PA, "2027") + original_cpi = parameter(original, CPI, "2027") + direct = ( + original.clone() + if copy_kind == "clone" + else original.get_branch("copy", clone_system=True) + ) + applied = original.clone() + scenario.simulation_modifier(direct) + scenario.apply(applied) + for path in (PA, CPI): + for year in YEARS: + assert parameter(direct, path, str(year)) == parameter( + applied, path, str(year) + ) + if representation == "cpi_dict": + assert parameter(direct, CPI, "2027") == 0.08 + else: + assert parameter(direct, PA, "2027") == pytest.approx(original_allowance * 1.1) + assert parameter(original, PA, "2027") == original_allowance + assert parameter(original, CPI, "2027") == original_cpi + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_mixed_replay_on_copy_changes_its_own_construction_reading(copy_kind): + changes = { + BASIC_RATE: {"2026-06-01.2027-03-31": 0.31}, + VAT: {"2027-01-01": 0.25}, + } + original = Simulation(situation=SITUATION, reform=changes) + assert parameter(original, BASIC_RATE, "2027-01-01") == 0.20 + simulation = ( + original.clone() + if copy_kind == "clone" + else original.get_branch("copy", clone_system=True) + ) + Scenario.from_reform(changes).simulation_modifier(simulation) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(simulation, VAT, "2027-01-01") == 0.25 + assert parameter(original, BASIC_RATE, "2027-01-01") == 0.20 + + +def test_indexed_freeze_needs_construction_for_indexation_resume(): + changes = {PA: {"2026-04-06.2033-04-05": 12_570}} + constructed = Simulation(situation=SITUATION, reform=changes) + built = Simulation(situation=SITUATION) + baseline_2034 = parameter(built, PA, "2034") + Scenario.from_reform(changes).apply(built) + increase = Decimal(str(parameter(constructed, STATUTORY_CPI, "2033"))) + expected = float( + (Decimal(12_570) * (1 + increase) / 10).to_integral_value( + rounding=ROUND_CEILING + ) + * 10 + ) + assert parameter(constructed, PA, "2033") == expected + assert parameter(built, PA, "2033") == 12_570 + assert parameter(built, PA, "2034") == baseline_2034 diff --git a/policyengine_uk/utils/scenario.py b/policyengine_uk/utils/scenario.py index 021e070b1f..3871b09481 100644 --- a/policyengine_uk/utils/scenario.py +++ b/policyengine_uk/utils/scenario.py @@ -72,6 +72,78 @@ def _is_reform_from_dict(reform: Type[Reform]) -> bool: ) +class ConstructionReformState: + """The constructor's writes, until user code edits their parameter paths. + + Ownership compares contents so copied scenarios and member wrappers keep + the construction reading. Current values alone cannot identify writes: + a foreign override can have the same value but a different date reading. + """ + + def __init__(self, simulation): + from policyengine_uk.tax_benefit_system import ( + parameter_reads_calendar_years, + reform_parameter_defines, + ) + + self.owned = copy.deepcopy(tuple(simulation._building_dated_reforms or ())) + self.paths = set() + for dated in self.owned: + assignments, _ = dated.read( + reform_parameter_defines(), + calendar_years=parameter_reads_calendar_years(), + ) + self.paths.update(entry.path for entry in assignments) + self.dirty = set() + self.expected = {} + self.refresh_framework(simulation) + + @staticmethod + def _current(simulation, path): + system = simulation.tax_benefit_system + target = system.parameters.get_child(path) + return copy.deepcopy( + ( + [(entry.instant_str, entry.value) for entry in target.values_list], + [entry for entry in system.dated_assignments if entry.path == path], + [entry for entry in system.legacy_overrides if entry.path == path], + ) + ) + + def _check_paths(self, simulation, paths): + for path in paths: + if self.expected[path] != self._current(simulation, path): + self.dirty.add(path) + return not self.dirty.intersection(paths) + + def pristine(self, simulation, dated_reforms): + if any(dated not in self.owned for dated in dated_reforms): + return False + paths = self.paths.intersection( + path for dated in dated_reforms for path in dated.changes + ) + return self._check_paths(simulation, paths) + + def accept_owned(self, simulation, dated): + # Only called after a pristine own modifier writes its leftovers. + for path in self.paths.intersection(dated.changes): + self.expected[path] = self._current(simulation, path) + + def observe_user_edits(self, simulation): + self._check_paths(simulation, self.paths) + + def refresh_framework(self, simulation): + # The framework may change clean paths between the two user phases. + # A user edit observed before that boundary must not become owned. + for path in self.paths - self.dirty: + self.expected[path] = self._current(simulation, path) + + +def _construction_modifiers_are_pristine(dated_reforms, simulation): + state = getattr(simulation, "_construction_reform_state", None) + return state is not None and state.pristine(simulation, dated_reforms) + + def _legacy_override_modifier(dated: DatedReform) -> Callable[[Simulation], None]: """Override changes that are no longer effective on the simulation. @@ -90,13 +162,26 @@ def modifier(simulation: Simulation) -> None: # Scenario.apply edits an already-built simulation's annualised # parameters, so there every change of the reform is an override. - every_change = getattr(simulation, APPLYING_TO_BUILT_SIMULATION, False) + applying = getattr(simulation, APPLYING_TO_BUILT_SIMULATION, False) + construction_owned = not applying and _construction_modifiers_are_pristine( + (dated,), simulation + ) + state = getattr(simulation, "_construction_reform_state", None) + construction_replay = ( + not applying + and state is not None + and dated in state.owned + and not construction_owned + ) + every_change = applying or construction_replay + if construction_replay: + _warn_annualised_overrides() if every_change: _, legacy = read_override_reform( dated.changes, dated.reading, lambda path: False ) overriding = True - elif dated in (getattr(simulation, "_building_dated_reforms", None) or ()): + elif construction_owned: _, legacy = dated.read( reform_parameter_defines(), warn=True, @@ -114,11 +199,7 @@ def modifier(simulation: Simulation) -> None: parameter_reads_calendar_years(), simulation.tax_benefit_system, ) - overriding = overriding or ( - bool(legacy) - and dated - not in (getattr(simulation, "_building_dated_reforms", None) or ()) - ) + overriding = overriding or (bool(legacy) and not construction_owned) if legacy: if overriding: if not every_change: @@ -138,6 +219,8 @@ def modifier(simulation: Simulation) -> None: require_annual_value=overriding, ) raise + if construction_owned: + simulation._construction_reform_state.accept_owned(simulation, dated) from policyengine_uk.scenarios.uc_reform import warn_shadowed_lcwra_reform warn_shadowed_lcwra_reform(simulation) @@ -238,7 +321,7 @@ def chained(simulation: Simulation) -> None: replay = ( dated is not None and not applying - and not getattr(simulation, "_building_dated_reforms", None) + and not _construction_modifiers_are_pristine(dated, simulation) ) if replay: _warn_annualised_overrides() From 92b6a3031c22acd0ae04ee70ff89c8f51d5a9bba 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 16:52:25 +0100 Subject: [PATCH 082/224] Isolate construction reform state in cloned simulations --- policyengine_uk/simulation.py | 3 + .../test_construction_reform_wrappers.py | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 56d5b35eae..b2cdd10e82 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -459,6 +459,9 @@ def set_input(self, variable_name: str, period: Period, value) -> None: def clone(self, *args, **kwargs) -> "Simulation": clone = super().clone(*args, **kwargs) + clone._construction_reform_state = copy.deepcopy( + getattr(self, "_construction_reform_state", None) + ) copy_model_values(self, clone) clone._cgt_input_provenance = copy.deepcopy( getattr(self, "_cgt_input_provenance", {}) diff --git a/policyengine_uk/tests/test_construction_reform_wrappers.py b/policyengine_uk/tests/test_construction_reform_wrappers.py index 437b633165..04a29ea6a1 100644 --- a/policyengine_uk/tests/test_construction_reform_wrappers.py +++ b/policyengine_uk/tests/test_construction_reform_wrappers.py @@ -346,3 +346,82 @@ def test_indexed_freeze_needs_construction_for_indexation_resume(): assert parameter(constructed, PA, "2033") == expected assert parameter(built, PA, "2033") == 12_570 assert parameter(built, PA, "2034") == baseline_2034 + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_isolated_construction_copy_preserves_parent_reading_and_tax(copy_kind): + situation = copy.deepcopy(SITUATION) + situation["people"]["person1"]["employment_income"] = { + year: 30_000 for year in YEARS + } + observations = {} + + def signature(simulation): + system = simulation.tax_benefit_system + target = system.parameters.get_child(BASIC_RATE) + return copy.deepcopy( + ( + [(entry.instant_str, entry.value) for entry in target.values_list], + system.dated_assignments, + system.legacy_overrides, + ) + ) + + def wrapper(parent): + before = signature(parent) + parent_state = parent._construction_reform_state + checkpoint_before = copy.deepcopy(parent_state.__dict__) + child = ( + parent.clone(clone_tax_benefit_system=True) + if copy_kind == "clone" + else parent.get_branch("construction_copy", clone_system=True) + ) + assert child.tax_benefit_system is not parent.tax_benefit_system + child_state = child._construction_reform_state + observations["separate_checkpoint_fields"] = ( + child_state is not parent_state + and all( + getattr(child_state, field) is not getattr(parent_state, field) + for field in ("owned", "paths", "dirty", "expected") + ) + and child_state.expected[BASIC_RATE] + is not parent_state.expected[BASIC_RATE] + ) + assert child_state.owned == parent_state.owned + child.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + child.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(child) + assert parameter(child, BASIC_RATE, "2027-01-01") == 0.31 + assert signature(parent) == before + assert parameter(parent, BASIC_RATE, "2027-01-01") == 0.20 + observations["parent_checkpoint_unchanged"] = ( + parent_state.__dict__ == checkpoint_before + ) + Scenario.from_reform(FISCAL_A).simulation_modifier(parent) + observations["parent_timeline_and_logs_unchanged"] = signature(parent) == before + + own = Scenario.from_reform(FISCAL_A) + control = Simulation(situation=situation, scenario=own) + simulation = Simulation( + situation=situation, + scenario=own + Scenario(simulation_modifier=wrapper), + ) + np.testing.assert_array_equal(control.calculate("income_tax", 2027), [3_486.0]) + np.testing.assert_array_equal( + simulation.calculate("employment_income", 2027), [30_000] + ) + np.testing.assert_array_equal( + simulation.calculate("personal_allowance", 2027), [12_570] + ) + np.testing.assert_array_equal( + simulation.calculate("income_tax", 2027), control.calculate("income_tax", 2027) + ) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert observations == { + "separate_checkpoint_fields": True, + "parent_checkpoint_unchanged": True, + "parent_timeline_and_logs_unchanged": True, + } From a8fcd4c4237d195aa4591952a2205422fd0dbb8b 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 18:30:39 +0100 Subject: [PATCH 083/224] Add configurable Income Support withdrawal rate --- changelog.d/332.added.md | 1 + .../means_test/withdrawal_rate.yaml | 11 + .../income_support_withdrawal_rate.yaml | 447 ++++++++++++++++++ .../gov/dwp/income_support_entitlement.py | 3 +- 4 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 changelog.d/332.added.md create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml diff --git a/changelog.d/332.added.md b/changelog.d/332.added.md new file mode 100644 index 0000000000..ae1a10b2b3 --- /dev/null +++ b/changelog.d/332.added.md @@ -0,0 +1 @@ +- Added a configurable Income Support withdrawal rate, preserving the existing 100% default. diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml new file mode 100644 index 0000000000..6e498ee614 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml @@ -0,0 +1,11 @@ +description: Share of assessable income deducted from the Income Support applicable amount. +values: + # Model anchor matching the existing means-test parameters, not legal commencement. + 2015-04-01: 1 +metadata: + period: year + unit: /1 + label: Income Support withdrawal rate + reference: + - title: Social Security Contributions and Benefits Act 1992, section 124(4) (6 April 2024) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/124/2024-04-06#section-124-4 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml new file mode 100644 index 0000000000..aa927317f4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml @@ -0,0 +1,447 @@ +# Existing annual-model continuing-award proxy: the parent reports Income Support. +# No eligibility, allowance, assessable income or cash award is forced. The public +# YAML dotted-input route reforms only the new dimensionless withdrawal rate. +# 2024 model controls: 90.50 * 52 = 4,706; lone-parent disregard 20 * 52 = 1,040. +# Pay 6,500 gives assessable income 5,460: default cash 0, half-rate cash 1,976. +# Pay 2,000 gives assessable income 960: default cash 3,746, half-rate cash 4,226. +# These inherited allowance/disregard controls are not a new rates or historical audit. + +- name: Default withdrawal exhausts an award above the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 0 + income_support_applicable_amount: 4706 + income_support_applicable_income: 5460 + income_support_eligible: true + income_tax: + - 0 + - 0 + national_insurance: + - 0 + - 0 + would_claim_IS: true +- name: Default withdrawal preserves cash below the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 3746 + income_support_applicable_amount: 4706 + income_support_applicable_income: 960 + income_support_eligible: true +- name: Default withdrawal pays the full allowance at zero income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 0 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 4706 + income_support_applicable_income: 0 + income_support_eligible: true +- name: A half withdrawal rate pays cash above the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 1976 + income_support_applicable_amount: 4706 + income_support_applicable_income: 5460 + income_support_eligible: true +- name: A half withdrawal rate applies only to income below the old cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 4226 + income_support_applicable_amount: 4706 + income_support_applicable_income: 960 +- name: A half withdrawal rate preserves the full allowance at zero income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 0 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 4706 +- name: A zero withdrawal rate preserves the allowance despite assessable income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0 + output: + income_support: 4706 + income_support_applicable_income: 5460 +- name: A double withdrawal rate multiplies income rather than the allowance + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 2 + output: + income_support: 2786 + income_support_applicable_income: 960 +- name: The zero floor still applies under a double withdrawal rate + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 2 + output: + income_support: 0 + income_support_eligible: true +- name: A half withdrawal rate does not create an award without an existing report + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 0 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + # No reports enable the unchanged claim-all fallback, but not eligibility. + would_claim_IS: true + income_support_eligible: false +- name: A half withdrawal rate preserves the remunerative work gate + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 832 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + income_support_remunerative_work_hours: + - 16 + - 0 + income_support_eligible: false +- name: A half withdrawal rate preserves the capital limit + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + savings: 16001 + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + income_support_assessable_capital: 16001 + income_support_eligible: false +- name: A half withdrawal rate does not reopen Income Support in 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + legacy_benefits_closed: true +- name: A half withdrawal rate preserves tax credit migration closure + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child_tax_credit_reported: 100 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + legacy_benefits_closed: true diff --git a/policyengine_uk/variables/gov/dwp/income_support_entitlement.py b/policyengine_uk/variables/gov/dwp/income_support_entitlement.py index 3cfebc284a..e27374ebc2 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/income_support_entitlement.py @@ -12,4 +12,5 @@ def formula(benunit, period, parameters): amount = benunit("income_support_applicable_amount", period) income = benunit("income_support_applicable_income", period) eligible = benunit("income_support_eligible", period) - return max_(0, amount - income) * eligible + rate = parameters(period).gov.dwp.income_support.means_test.withdrawal_rate + return max_(0, amount - rate * income) * eligible 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 084/224] 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 085/224] 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 086/224] 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 e9a2334bcf3546a98db8ce129a5ae355b5ab3bb4 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 10:07:49 +0100 Subject: [PATCH 087/224] Use the Housing Benefit pension deduction parameter consistently --- ...40-hb-pension-deduction-parameter.fixed.md | 1 + .../housing_benefit_applicable_income.yaml | 136 ++++++++++++++++++ .../housing_benefit_applicable_income.py | 4 +- 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 changelog.d/140-hb-pension-deduction-parameter.fixed.md diff --git a/changelog.d/140-hb-pension-deduction-parameter.fixed.md b/changelog.d/140-hb-pension-deduction-parameter.fixed.md new file mode 100644 index 0000000000..b3b4e5d473 --- /dev/null +++ b/changelog.d/140-hb-pension-deduction-parameter.fixed.md @@ -0,0 +1 @@ +- Fixed Housing Benefit applicable income to use the configured pension contribution deduction rate, preserving the current 50% deduction. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml index f5218d1b0b..d0370fcfbc 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml @@ -127,3 +127,139 @@ housing_benefit_applicable_income_childcare_element: 0 output: housing_benefit_applicable_income: 0 + +- name: Continuing council tenant retains the default half-pension deduction + # SI 2006/213 reg 36(3)(c): half of the personal pension contribution. + # Earnings are below the tax and NI thresholds. Net earnings are + # 12,000 - 0.5 * 2,000 = 11,000; the single earnings disregard is 5 * 52. + # Applicable income is 10,740 and the allowance is 92.05 * 52 = 4,786.60. + # HB = 10,000 - 0.65 * (10,740 - 4,786.60) = 6,130.29. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 # continuing award, not calculated cash + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 6_130.29 + housing_benefit_applicable_income: 10_740 + housing_benefit_net_earnings: 11_000 + housing_benefit_applicable_income_disregard: 260 + housing_benefit_applicable_amount: 4_786.60 + pension_contributions: 2_000 + income_tax: 0 + national_insurance: 0 + housing_benefit_non_dep_deductions: 0 + housing_benefit_benefit_cap_reduction: 0 + housing_benefit_on_passporting_benefit: false + housing_benefit_eligible: true + would_claim_housing_benefit: true + legacy_benefits_closed: false + universal_credit: 0 + +- name: Quarter-pension deduction reform reduces the continuing Housing Benefit award + # Model reform, not current law: 12,000 - 0.25 * 2,000 - 260 = 11,240. + # HB = 10,000 - 0.65 * (11,240 - 4,786.60) = 5,805.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 5_805.29 + housing_benefit_applicable_income: 11_240 + housing_benefit_net_earnings: 11_500 + housing_benefit_applicable_income_disregard: 260 + housing_benefit_applicable_amount: 4_786.60 + +- name: Zero pension deduction reform counts the full income + # Model reform: 12,000 - 0 * 2,000 - 260 = 11,740. + # HB = 10,000 - 0.65 * (11,740 - 4,786.60) = 5,480.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit_applicable_income: 11_740 + housing_benefit: 5_480.29 + housing_benefit_net_earnings: 12_000 + +- name: Full pension deduction reform increases the continuing Housing Benefit award + # Model reform: 12,000 - 1 * 2,000 - 260 = 9,740. + # HB = 10,000 - 0.65 * (9,740 - 4,786.60) = 6,780.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 1 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 6_780.29 + housing_benefit_applicable_income: 9_740 + housing_benefit_net_earnings: 10_000 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index b97d9ac316..b343e2b9e5 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -90,8 +90,10 @@ def formula(benunit, period, parameters): benunit, period, ["basic_income"], members ) # Reduce increased income by pension contributions and tax + p = parameters(period).gov.dwp.housing_benefit.means_test pension_contributions = ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) + * p.pension_contribution_deduction_rate ) TAX_COMPONENTS = ["income_tax", "national_insurance"] tax = add_for_members(benunit, period, TAX_COMPONENTS, members) From d539e33dfdb098d8c6159ea99e0f192eac17e035 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 12:10:44 +0100 Subject: [PATCH 088/224] Fix supplied formula input exports --- .../fix-2297-formula-input-export.fixed.md | 1 + policyengine_uk/simulation.py | 15 +- .../tests/test_dataframe_dataset.py | 5 - .../tests/test_input_dataframe_export.py | 299 ++++++++++++++++++ .../utils/input_dataframe_export.py | 79 +++++ 5 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fix-2297-formula-input-export.fixed.md create mode 100644 policyengine_uk/tests/test_input_dataframe_export.py create mode 100644 policyengine_uk/utils/input_dataframe_export.py diff --git a/changelog.d/fix-2297-formula-input-export.fixed.md b/changelog.d/fix-2297-formula-input-export.fixed.md new file mode 100644 index 0000000000..ae78f45baf --- /dev/null +++ b/changelog.d/fix-2297-formula-input-export.fixed.md @@ -0,0 +1 @@ +Preserve dataset-supplied values for variables with formulas in default input DataFrame exports, so reloading an export retains inputs such as reported State Pension. Exclude calculated caches and model-generated pseudo-inputs while retaining recorded input storage and branch precedence. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 2b7c724592..8d82ab2877 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -51,6 +51,7 @@ capital_gains_application_metadata, ) from policyengine_uk.utils.dependencies import get_variable_dependencies +from policyengine_uk.utils.input_dataframe_export import input_dataframe from policyengine_uk.utils.supplied_inputs import ( SUPPLIED_INPUT_VARIABLES, copy_model_values, @@ -1039,7 +1040,9 @@ def to_input_dataframe( """Export the simulation's inputs as a DataFrame that ``Simulation(dataset=...)`` loads back. - policyengine-core exports each input for the periods it was set for. + Export supplied values, including overrides of variables with formulas, + for the periods they were set for. Calculated caches and values stored + by the model itself are not supplied inputs. The identifiers are also given for every whole year a column names, where a simulation built from the frame holds them, so that the frame loads back to a simulation that exports the same frame. See @@ -1049,12 +1052,12 @@ def to_input_dataframe( include_computed_variables: If True, export every stored value, calculated ones included, as policyengine-core does. """ - return with_identifiers_in_each_year( - super().to_input_dataframe( - include_computed_variables=include_computed_variables - ), - self.tax_benefit_system, + frame = ( + super().to_input_dataframe(include_computed_variables=True) + if include_computed_variables + else input_dataframe(self) ) + return with_identifiers_in_each_year(frame, self.tax_benefit_system) def build_from_dataset(self, dataset: Dataset) -> None: """Build simulation from a Dataset object. diff --git a/policyengine_uk/tests/test_dataframe_dataset.py b/policyengine_uk/tests/test_dataframe_dataset.py index 0f803680d0..38ae8c7841 100644 --- a/policyengine_uk/tests/test_dataframe_dataset.py +++ b/policyengine_uk/tests/test_dataframe_dataset.py @@ -59,11 +59,6 @@ group's own integer identifier is given beside it; and members of a group that give different values for one of its variables. -Limit of the default export, which is not the loader's: ``to_input_dataframe()`` -leaves out values a dataset supplies for a variable that has a formula, such -as ``state_pension_reported``. ``to_input_dataframe(include_computed_variables= -True)`` exports them, and must be taken before the simulation calculates -anything, since it also exports every calculated value. """ import io diff --git a/policyengine_uk/tests/test_input_dataframe_export.py b/policyengine_uk/tests/test_input_dataframe_export.py new file mode 100644 index 0000000000..f983c7dc13 --- /dev/null +++ b/policyengine_uk/tests/test_input_dataframe_export.py @@ -0,0 +1,299 @@ +"""Input export preserves supplied values, including formula overrides. + +All amounts below are synthetic input identities, not statutory rate oracles. +""" + +import numpy as np +import pandas as pd +import pytest +from policyengine_core.data_storage import OnDiskStorage +from policyengine_core.periods import period +from policyengine_core.simulations import Simulation as CoreSimulation + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.input_dataframe import with_identifiers_in_each_year +from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.model_api import Reform +from policyengine_uk.utils.supplied_inputs import set_model_value + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2025 +FORMULA = "state_pension_reported" + + +def built(): + return Simulation( + situation={ + "people": {"person": {"age": {YEAR: 71}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + + +def values(simulation, variable, year=YEAR): + return simulation.to_input_dataframe()[f"{variable}__{year}"].tolist() + + +def test_dataset_formula_input_round_trip_preserves_final_pension(): + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [71], + FORMULA: [9_500.0], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "council_tax": [0], + "tenure_type": ["OWNED_OUTRIGHT"], + "rent": [0], + } + ), + fiscal_year=YEAR, + ) + original = Simulation(dataset=dataset, scenario=no_economic_assumptions) + assert original.calculate("state_pension", YEAR).tolist() == [9_500] + exported = original.to_input_dataframe() + reloaded = Simulation(dataset=exported, scenario=no_economic_assumptions) + assert reloaded.calculate("state_pension", YEAR).tolist() == [9_500] + assert exported[f"{FORMULA}__{YEAR}"].tolist() == [9_500] + assert reloaded.calculate(FORMULA, YEAR).tolist() == [9_500] + + +@pytest.mark.parametrize("variable", ["hours_worked", FORMULA, "employment_income"]) +def test_supplied_structural_formula_and_adds_values_are_exported(variable): + sim = built() + sim.set_input(variable, YEAR, [100]) + assert values(sim, variable) == [100] + + +def test_export_does_not_calculate_values(monkeypatch): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + + def calculate(*args, **kwargs): + pytest.fail("default export must read supplied arrays, not calculate") + + monkeypatch.setattr(sim, "calculate", calculate) + assert values(sim, FORMULA) == [100] + + +def test_derived_only_and_carried_periods_are_not_inputs(): + sim = built() + sim.set_input("hours_worked", YEAR, [100]) + assert sim.calculate("hours_worked", YEAR + 1).tolist() == [100] + sim.calculate("state_pension", YEAR) + exported = sim.to_input_dataframe() + assert "hours_worked__2026" not in exported + assert "state_pension__2025" not in exported + assert exported["hours_worked__2025"].tolist() == [100] + + +@pytest.mark.parametrize("disk_survives", [False, True]) +def test_only_a_surviving_recorded_storage_tier_is_exported(disk_survives, tmp_path): + sim = built() + sim.set_input("hours_worked", YEAR, [100]) + holder = sim.get_holder("hours_worked") + source_period = period(YEAR) + if disk_survives: + holder._disk_storage = OnDiskStorage(str(tmp_path)) + holder._disk_storage.put(np.array([100.0]), source_period, sim.branch_name) + holder._user_input_storage[(sim.branch_name, str(source_period))] = ( + source_period, + frozenset({"disk"}), + ) + holder._memory_storage.put( + np.array([999.0]), source_period, sim.branch_name, derived=True + ) + exported = sim.to_input_dataframe() + if disk_survives: + assert exported["hours_worked__2025"].tolist() == [100] + else: + assert "hours_worked__2025" not in exported + + +@pytest.mark.parametrize("actual_input", [False, True]) +def test_model_values_are_omitted_until_a_real_input_is_set(actual_input): + sim = built() + set_model_value(sim, "adjusted_net_income", YEAR, np.array([123.0])) + if actual_input: + sim.set_input("adjusted_net_income", YEAR, [123]) + assert values(sim, "adjusted_net_income") == [123] + else: + assert "adjusted_net_income__2025" not in sim.to_input_dataframe() + + +def test_deleted_array_with_a_stale_key_is_not_exported(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + holder._memory_storage.delete(period(YEAR)) + assert (FORMULA, sim.branch_name, period(YEAR)) in sim._user_input_keys + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_copies_keep_independent_inputs_and_model_markers(copy_kind): + parent = built() + set_model_value(parent, "adjusted_net_income", YEAR, np.array([123.0])) + parent.set_input(FORMULA, YEAR, [100]) + child = parent.clone() if copy_kind == "clone" else parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + child.set_input("adjusted_net_income", YEAR, [123]) + assert values(parent, FORMULA) == [100] + assert values(child, FORMULA) == [200] + assert "adjusted_net_income__2025" not in parent.to_input_dataframe() + assert values(child, "adjusted_net_income") == [123] + + +def test_nearest_surviving_branch_input_precedes_ancestors(): + parent = built() + parent.set_input(FORMULA, YEAR, [100]) + child = parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + nested = child.get_branch("nested") + assert values(nested, FORMULA) == [200] + nested.set_input(FORMULA, YEAR, [300]) + assert values(nested, FORMULA) == [300] + assert values(parent, FORMULA) == [100] + + +def test_entity_projection_row_order_dates_and_round_trip(): + supplied = pd.DataFrame( + { + "person_id__2025": [30, 10, 20], + "person_benunit_id__2025": [2, 1, 2], + "person_household_id__2025": [2, 1, 2], + "age__2025": [40, 41, 42], + "benunit_reported_capital__2025": [200, 100, 200], + "council_tax__2025": [2_000, 1_000, 2_000], + "state_pension_reported__2025": [300, 100, 200], + } + ) + sim = Simulation(dataset=supplied) + sim.set_input(FORMULA, 2027, [600, 400, 500]) + exported = sim.to_input_dataframe() + for variable in ("benunit_reported_capital", "council_tax", FORMULA): + assert ( + exported[f"{variable}__2025"].tolist() + == supplied[f"{variable}__2025"].tolist() + ) + assert exported[f"{FORMULA}__2027"].tolist() == [600, 400, 500] + assert exported["person_id__2027"].tolist() == [30, 10, 20] + reloaded = Simulation(dataset=exported) + pd.testing.assert_frame_equal(reloaded.to_input_dataframe().copy(), exported.copy()) + + +def test_only_periods_matching_the_variable_definition_are_exported(): + sim = built() + holder = sim.get_holder(FORMULA) + monthly = period("2025-02") + holder._memory_storage.put(np.array([100.0]), monthly, sim.branch_name) + sim._user_input_keys.add((FORMULA, sim.branch_name, monthly)) + assert f"{FORMULA}__2025-02" not in sim.to_input_dataframe() + + +def test_annual_periods_keep_their_dates_and_sorted_order(): + sim = built() + for source_period, amount in [(2027, 300), (2025, 100), (2026, 200)]: + sim.set_input(FORMULA, source_period, [amount]) + exported = sim.to_input_dataframe() + columns = [name for name in exported if name.startswith(f"{FORMULA}__")] + assert columns == [f"{FORMULA}__{year}" for year in (2025, 2026, 2027)] + assert exported[columns].iloc[0].tolist() == [100, 200, 300] + + +def test_an_array_without_an_input_record_is_not_exported(): + sim = built() + sim.get_holder(FORMULA)._memory_storage.put(np.array([999.0]), period(YEAR)) + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +def test_register_only_input_uses_its_storage_marks(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + holder._user_input_storage.pop((sim.branch_name, str(period(YEAR)))) + assert values(sim, FORMULA) == [100] + + +def test_a_replaced_child_input_falls_back_to_the_surviving_parent(): + parent = built() + parent.set_input(FORMULA, YEAR, [100]) + child = parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + child.get_holder(FORMULA)._memory_storage.put( + np.array([999.0]), period(YEAR), child.branch_name, derived=True + ) + assert values(child, FORMULA) == [100] + assert values(parent, FORMULA) == [100] + + +def test_deletion_then_real_input_clears_a_model_marker(): + sim = built() + set_model_value(sim, "adjusted_net_income", YEAR, np.array([123.0])) + sim.delete_arrays("adjusted_net_income", YEAR) + sim.set_input("adjusted_net_income", YEAR, [123]) + assert values(sim, "adjusted_net_income") == [123] + + +def test_a_rebuild_does_not_export_a_dropped_input_or_its_derived_replacement(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + sim.build_from_situation( + { + "people": {"other": {"age": {YEAR: 40}}}, + "benunits": {"other": {"members": ["other"]}}, + "households": {"other": {"members": ["other"]}}, + } + ) + sim.get_holder(FORMULA)._memory_storage.put( + np.array([999.0]), period(YEAR), sim.branch_name, derived=True + ) + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +def test_a_reform_input_survives_a_calculation_reset(): + class EnterPension(Reform): + def apply(self): + self.simulation.set_input(FORMULA, YEAR, [100]) + + sim = built() + sim.apply_reform(EnterPension) + sim.reset_calculations() + assert values(sim, FORMULA) == [100] + + +def test_export_does_not_change_provenance_or_stored_values(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + keys = set(sim._user_input_keys) + records = dict(holder._user_input_storage) + stored = holder._memory_storage.get(period(YEAR)).copy() + first = sim.to_input_dataframe() + pd.testing.assert_frame_equal(sim.to_input_dataframe().copy(), first.copy()) + assert sim._user_input_keys == keys + assert holder._user_input_storage == records + np.testing.assert_array_equal(holder._memory_storage.get(period(YEAR)), stored) + + +def test_computed_export_keeps_core_delegation(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + sim.calculate("state_pension", YEAR) + expected = with_identifiers_in_each_year( + CoreSimulation.to_input_dataframe(sim, include_computed_variables=True), + sim.tax_benefit_system, + ) + pd.testing.assert_frame_equal( + sim.to_input_dataframe(include_computed_variables=True).copy(), expected.copy() + ) diff --git a/policyengine_uk/utils/input_dataframe_export.py b/policyengine_uk/utils/input_dataframe_export.py new file mode 100644 index 0000000000..afed6ca230 --- /dev/null +++ b/policyengine_uk/utils/input_dataframe_export.py @@ -0,0 +1,79 @@ +"""Export recorded inputs without treating formulas or caches as provenance.""" + +import pandas as pd + +from policyengine_uk.utils.supplied_inputs import _is_model_value + + +def _legacy_recorded_array(holder, period, branch_name): + """Read the surviving input tier on Core 3.32.27. + + Core 3.33.2 supplies ``Simulation._get_recorded_input_array``. The + supported minimum instead exposes only the holder's combined predicate; + mirror its recorded-location/has/not-derived checks before reading a tier. + An ordinary holder read would let a calculated memory value hide an input + still stored on disk. + """ + recorded = getattr(holder, "_user_input_storage", {}).get( + (branch_name, str(period)) + ) + locations = recorded[1] if recorded is not None else {"memory", "disk"} + for location, storage in ( + ("memory", holder._memory_storage), + ("disk", holder._disk_storage), + ): + if ( + location in locations + and storage is not None + and storage.has(period, branch_name) + and not storage.is_derived(period, branch_name) + ): + return storage.get(period, branch_name) + return None + + +def _recorded_array(simulation, variable, period, branch_names, input_keys): + holder = simulation.get_holder(variable) + for branch_name in branch_names: + key = (variable, branch_name, period) + if key not in input_keys or not holder._stores_user_input(period, branch_name): + continue + reader = getattr(simulation, "_get_recorded_input_array", None) + values = ( + reader(variable, period) + if reader is not None + else _legacy_recorded_array(holder, period, branch_name) + ) + # The Core reader and the fallback choose this same first surviving + # branch. Match the model marker to that source, not another branch + # whose input happens to have the same numeric value. + if values is None or _is_model_value(simulation, key, values): + return None + return values + return None + + +def input_dataframe(simulation): + """Project actual supplied arrays to people, including formula overrides. + + The simulation's existing input record, storage marks and UK model-value + markers remain authoritative. Export neither calculates nor records data. + """ + branch_names = simulation._get_visible_branch_names() + input_keys = getattr(simulation, "_user_input_keys", set()) + periods_by_variable = {} + for variable, branch_name, period in input_keys: + if branch_name in branch_names: + periods_by_variable.setdefault(variable, set()).add(period) + + columns = {} + for name, variable in simulation.tax_benefit_system.variables.items(): + for period in sorted(periods_by_variable.get(name, ()), key=str): + if variable.definition_period != period.unit: + continue + values = _recorded_array(simulation, name, period, branch_names, input_keys) + if values is not None: + columns[f"{name}__{period}"] = simulation.map_result( + values, variable.entity.key, simulation.persons.entity.key + ) + return pd.DataFrame(columns) From 1e98d9613ab19a70ef2035e24e5c3b6a3f42db87 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 23:29:56 +0100 Subject: [PATCH 089/224] Include SSP and SMP in pension-relief earnings --- changelog.d/2371.fixed.md | 1 + .../reliefs/pension_contribution/README.md | 18 ++ .../statutory_pay_pension_relief.yaml | 195 ++++++++++++++++++ .../pensions/pension_contributions_relief.py | 18 +- 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2371.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml diff --git a/changelog.d/2371.fixed.md b/changelog.d/2371.fixed.md new file mode 100644 index 0000000000..085dc698dd --- /dev/null +++ b/changelog.d/2371.fixed.md @@ -0,0 +1 @@ +- Included taxable statutory employment pay in the relevant earnings limit for pension contribution relief. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md index 724d510f14..ae3324a83b 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md @@ -1 +1,19 @@ # Pension contributions + +Relevant UK earnings for pension contribution relief include employer-paid, +taxable statutory sick pay and statutory maternity pay. The model holds these +payments in `employment_benefits`, separately from `employment_income`, and +includes that aggregate once when limiting relief. Pension income does not +increase the relevant-earnings limit. The existing age limit, relief-at-source +basic amount and nonnegative trading-profit rules still apply. + +This follows [Finance Act 2004 section 189](https://www.legislation.gov.uk/ukpga/2004/12/section/189) +and [HMRC PTM044100](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100). +The annual monetary inputs represent actual employer-paid taxable payments; +they do not establish statutory-pay eligibility or identify exceptional payments +made directly by HMRC in survey data. + +Statutory paternity pay still depends on the separate change in +[PR #2368](https://github.com/PolicyEngine/policyengine-uk/pull/2368) to include it +in the taxable employment aggregate. This change addresses the existing SSP/SMP +omission in issue #2371; it does not complete that issue's SPP dependency. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml new file mode 100644 index 0000000000..17e2d3121b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml @@ -0,0 +1,195 @@ +# Actual employer-paid annual SSP/SMP, occupational net-pay member contributions, +# and an already-awarded defined-benefit pension where specified. +# FA 2004 s. 189 and HMRC PTM044100: statutory employment pay is relevant earnings. + +- name: SSP final ordinary tax + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: SMP relief cap + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 35 + employment_income: 20000 + statutory_sick_pay: 0 + statutory_maternity_pay: 2000 + self_employment_income: 0 + private_pension_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 0.0 + +- name: Still capped at all relevant earnings + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 25000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 25000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Contributions already covered + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 12000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 12000 + pension_contributions_relief: 12000 + personal_pension_contributions_tax: 0 + income_tax: 3486.0 + +- name: Equal amount as wages + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 22000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 0 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Pension is not relevant earnings + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 0 + employee_pension_contributions: 22000 + pension_contributions_relief: 20000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Age75 exclusion retained + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 75 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 0 + personal_pension_contributions_tax: 0 + income_tax: 5886.0 + +- name: RAS basic amount retained + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 40 + employment_income: 1000 + statutory_sick_pay: 0 + statutory_maternity_pay: 1000 + self_employment_income: 0 + private_pension_income: 0 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + output: + employment_benefits: 1000 + employee_pension_contributions: 0 + pension_contributions_relief: 3600 + personal_pension_contributions_tax: 0 + income_tax: 0.0 + +- name: Positive trading profit + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 5000 + private_pension_income: 20000 + employee_pension_contributions_reported: 27000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 27000 + pension_contributions_relief: 27000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Trading losses do not offset statutory employment pay for relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + self_employment_income: -5000 + employee_pension_contributions_reported: 25000 + output: + pension_contributions_relief: 22000 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 8fdf23d848..4b7dc64191 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -14,6 +14,8 @@ 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. " "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." @@ -36,17 +38,23 @@ class pension_contributions_relief(Variable): title="Finance Act 2004 s. 191", href="https://www.legislation.gov.uk/ukpga/2004/12/section/191", ), + dict( + title="HMRC PTM044100: relevant UK earnings", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100", + ), ] unit = GBP def formula(person, period, parameters): hmrc = parameters(period).gov.hmrc contributions = person("pension_contributions", period) - # Relevant UK earnings (s. 189(2)) are employment income and trading - # income. A trading loss gives no relevant earnings; it does not - # reduce employment income. - relevant_uk_earnings = person("employment_income", period) + max_( - 0, person("self_employment_income", period) + # Relevant UK earnings (s. 189(2), PTM044100) include taxable statutory + # employment pay, held separately from wages in employment_benefits. + # A trading loss gives no relevant earnings; it does not reduce pay. + relevant_uk_earnings = ( + person("employment_income", period) + + person("employment_benefits", period) + + max_(0, person("self_employment_income", period)) ) return pension_contributions_relief_amount( contributions, From 750de1345b99e3e2d8c598ba02e2fff016c74a78 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 16:16:05 +0200 Subject: [PATCH 090/224] 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 091/224] 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 4d4787e654e7e3bc0a48500fb2c8beb8c78ea306 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 15:36:39 +0100 Subject: [PATCH 092/224] Align statutory pay in pension annual-allowance earnings --- changelog.d/2371.fixed.md | 2 +- .../statutory_pay_pension_relief.yaml | 48 +++++++++++++++++++ policyengine_uk/utils/pensions.py | 19 ++++++-- ...nsion_annual_allowance_threshold_income.py | 7 +-- .../pensions/pension_contributions_relief.py | 15 ++---- 5 files changed, 70 insertions(+), 21 deletions(-) diff --git a/changelog.d/2371.fixed.md b/changelog.d/2371.fixed.md index 085dc698dd..204233b876 100644 --- a/changelog.d/2371.fixed.md +++ b/changelog.d/2371.fixed.md @@ -1 +1 @@ -- Included taxable statutory employment pay in the relevant earnings limit for pension contribution relief. +- Included statutory sick and maternity pay in the relevant earnings limits for pension contribution relief and annual-allowance income tests. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml index 17e2d3121b..ebf7430268 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml @@ -193,3 +193,51 @@ employee_pension_contributions_reported: 25000 output: pension_contributions_relief: 22000 + +# Supplied ANI is an explicit input contract. Restore the same actual pension +# relief for equal total relevant earnings, regardless of the SSP wage split. +- name: Supplied ANI all-wages annual allowance control + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income: 22000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5000 + output: + employment_benefits: 0 + pension_contributions_relief: 22000 + net_income: 27000 + pension_contributions_for_annual_allowance: 22000 + pension_annual_allowance_threshold_income: 5000 + pension_annual_allowance_adjusted_income: 27000 + +- name: Supplied ANI SSP preserves annual allowance earnings base + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5000 + output: + employment_benefits: 2000 + pension_contributions_relief: 22000 + net_income: 27000 + pension_contributions_for_annual_allowance: 22000 + pension_annual_allowance_threshold_income: 5000 + pension_annual_allowance_adjusted_income: 27000 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 754fd23a2d..4c9b81729c 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -3,6 +3,19 @@ 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. + + Statutory employment pay is held separately from wages. A trading loss + does not reduce those employment earnings (HMRC PTM044100). + """ + return ( + person("employment_income", period) + + person("employment_benefits", period) + + np.maximum(0, person("self_employment_income", period)) + ) + + def pension_contributions_relief_amount( contributions, relevant_uk_earnings, ras_contributions, age, hmrc ): @@ -39,11 +52,7 @@ def annual_allowance_net_income(person, period, parameters): returned = person("salary_sacrifice_returned_to_income", period) hmrc = parameters(period).gov.hmrc actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = ( - person("employment_income", period) - - returned - + np.maximum(0, person("self_employment_income", period)) - ) + actual_earnings = relevant_uk_earnings(person, period) - returned supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. 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 95f4db1d41..374353361c 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 @@ -2,6 +2,7 @@ from policyengine_uk.utils.pensions import ( annual_allowance_net_income, pension_contributions_relief_amount, + relevant_uk_earnings, ) @@ -37,11 +38,7 @@ def formula(person, period, parameters): # 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 = ( - person("employment_income", period) - - returned - + max_(0, person("self_employment_income", period)) - ) + actual_earnings = relevant_uk_earnings(person, period) - returned relief = where( returned > 0, pension_contributions_relief_amount( 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 4b7dc64191..d9afcef026 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import pension_contributions_relief_amount +from policyengine_uk.utils.pensions import ( + pension_contributions_relief_amount, + relevant_uk_earnings, +) class pension_contributions_relief(Variable): @@ -48,17 +51,9 @@ class pension_contributions_relief(Variable): def formula(person, period, parameters): hmrc = parameters(period).gov.hmrc contributions = person("pension_contributions", period) - # Relevant UK earnings (s. 189(2), PTM044100) include taxable statutory - # employment pay, held separately from wages in employment_benefits. - # A trading loss gives no relevant earnings; it does not reduce pay. - relevant_uk_earnings = ( - person("employment_income", period) - + person("employment_benefits", period) - + max_(0, person("self_employment_income", period)) - ) return pension_contributions_relief_amount( contributions, - relevant_uk_earnings, + relevant_uk_earnings(person, period), person("personal_pension_contributions", period), person("age", period), hmrc, From a6a975001f332a0017373b00d5ad416e7d19beea Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 17:00:25 +0200 Subject: [PATCH 093/224] 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 094/224] 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 095/224] 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 096/224] 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 097/224] 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 098/224] 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 f400f2492fbb789d3c595857e7dc5dd7306a78b7 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 20:17:34 +0200 Subject: [PATCH 099/224] Use main's Simulation.apply_reform instead of a second definition #2075 added Simulation.apply_reform on main, which binds tax_benefit_system.simulation while a reform is applied. This branch added its own apply_reform later in the class body, which would have silently replaced #2075's (and dropped its input logging and CGT refresh). Remove this branch's copy so main's single definition is used; it already lets adjust_budgets read the simulation, including on a clone. Reword the changelog fragment to describe what this branch still fixes: the adjust_budgets read-before-write and zero-allocation guard. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2188.fixed.md | 2 +- policyengine_uk/simulation.py | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/changelog.d/2188.fixed.md b/changelog.d/2188.fixed.md index ae0863c450..7ea3ab08e7 100644 --- a/changelog.d/2188.fixed.md +++ b/changelog.d/2188.fixed.md @@ -1 +1 @@ -Structural reforms applied by a UK `Simulation` can now read the simulation they adjust, so a before-load `Scenario` change to DfE, DHSC or DfT spending constructs and scales each year's allocation without carrying an earlier year's change forward. +A before-load `Scenario` change to DfE, DHSC or DfT spending now constructs: `adjust_budgets` skips a year with no allocation instead of writing NaN, and scales each year's allocation by that year's own budget change without carrying an earlier year's change forward. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index c3e87d5aef..2b7c724592 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -523,18 +523,6 @@ def _user_input_keys(self) -> None: except KeyError: raise AttributeError("_user_input_keys") from None - def apply_reform(self, reform) -> None: - # policyengine-core's Simulation.apply_reform calls - # ``reform.apply(self.tax_benefit_system)``, and core's - # Simulation.__init__ binds ``tax_benefit_system.simulation`` before - # it applies reforms. This class does not call core's __init__, so - # make the binding here. Structural reforms that adjust inputs - # (adjust_budgets, disable_simulated_benefits) read it. Binding on - # every call also keeps a cloned system pointing at the simulation - # that applies the reform rather than the one it was cloned from. - self.tax_benefit_system.simulation = self - super().apply_reform(reform) - def delete_arrays(self, variable: str, period: Period = None) -> None: super().delete_arrays(variable, period) # From policyengine-core 3.32.27 (policyengine-core#561) the deletion From f84bc24b46ab08b7b318fd3861276948c96e2717 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 04:09:20 +0100 Subject: [PATCH 100/224] Document employer National Insurance incidence assumptions --- changelog.d/2168.changed.md | 1 + .../book/assumptions/employer-ni-incidence.md | 183 ++++++++++++++++++ .../policyengine/employer_ni/README.md | 24 ++- .../employer_ni/capital_incidence.yaml | 6 +- .../employer_ni/consumer_incidence.yaml | 6 +- .../employer_ni/employee_incidence.yaml | 6 +- 6 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2168.changed.md create mode 100644 docs/book/assumptions/employer-ni-incidence.md diff --git a/changelog.d/2168.changed.md b/changelog.d/2168.changed.md new file mode 100644 index 0000000000..1d6027d123 --- /dev/null +++ b/changelog.d/2168.changed.md @@ -0,0 +1 @@ +- Documented employer NI incidence assumptions, input handling, revenue measures and current household examples. diff --git a/docs/book/assumptions/employer-ni-incidence.md b/docs/book/assumptions/employer-ni-incidence.md new file mode 100644 index 0000000000..b5021aa515 --- /dev/null +++ b/docs/book/assumptions/employer-ni-incidence.md @@ -0,0 +1,183 @@ +# Employer National Insurance incidence + +Employer National Insurance reforms can change wages, tax receipts and household +income through the model's incidence assumptions. The parameters under +`gov.contrib.policyengine.employer_ni` default to: + +| Parameter | Default | Implemented role | +|---|---:|---| +| `employee_incidence` | 1 | Scales the computed fixed-employer-cost pay adjustment | +| `consumer_incidence` | 0.5 | Allocates remaining employer-cost changes by consumption shares | +| `capital_incidence` | 0.5 | Allocates remaining employer-cost changes by corporate wealth shares | + +These values are adjustable model assumptions. No empirical estimate is claimed +for the defaults, and this page does not select a preferred incidence assumption. + +## Wage adjustment and supplied income + +The wage channel compares the baseline and reform employer-NI rate, secondary +threshold and employer-pension exemption. For its eligible earnings branch, it +solves for pay holding modeled employer cost fixed, then multiplies the pay change +by `employee_incidence`. At `1`, the full computed adjustment applies in the same +model year. The formula returns zero without a baseline, without a relevant +parameter change, at employee incidence zero, or for prior pay below the baseline +secondary threshold. This describes the implemented calculation, including its +threshold guard; it does not assert a general equilibrium response for every worker. + +`employment_income` adds this adjustment to `employment_income_before_lsr` and +other earnings components. An input actually stored in the final +`employment_income` holder overrides its formula. The UK `Simulation` constructor +moves situation inputs named `employment_income` to `employment_income_before_lsr`. +Consequently, putting that field in a constructor situation can still allow the +wage response. The example below demonstrates an override with `set_input` after +construction and before calculation. Dataset and other input routes should be +checked for which earnings holder they populate. + +The [wage formula](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py), +[earnings components](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/input/employment_income.py) +and [constructor remapping](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/simulation.py#L350) +provide the implementation details. + +## Consumer and capital channels + +Consumer and capital incidence apply to the remaining change in modeled employer +cost after the wage response. Each is zero when its own parameter is zero or +employee incidence is one. Otherwise, the formulas use person weights and allocate +that change using household consumption or corporate wealth per person. Their +weighted aggregate exposure must be nonzero; they also return zero when the +simulation has no dataset attribute. + +An attribute can be present with `dataset=None`, so that guard alone is not a +promise that every household calculation has zero residual incidence. The example +below has no representative dataset or consumption/wealth exposures and returns +zero for both residual channels. It cannot show their population effects. + +Setting `employee_incidence` to zero alone leaves the consumer and capital +parameters at `0.5`. Set **all three parameters to zero** to disable these three +employer-NI incidence channels. This does not disable unrelated labor-supply or +other behavioral responses. + +The response amounts enter `household_tax`; those same variables are not added to +`gov_tax`. See the [consumer](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_consumer_incidence.py) +and [capital](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_capital_incidence.py) +allocation formulas. + +## Measures and aggregation + +Report these quantities separately: + +- Gross employer-NI liability or its reform change: `ni_employer`, a person amount. +- Net government balance: `gov_balance = gov_tax - gov_spending`, a household amount. + A positive reform change improves the modeled balance. Wage changes can affect + income tax, employee NI and benefits, so this is not a gross employer-NI yield. +- Household incidence here: the change in nominal `household_net_income`, defined + as `household_market_income + household_benefits - household_tax - pension_contributions`. + A negative change is a household loss under this measure. + +The example sums each output over one person or one household, as appropriate. +Population results require the matching person or household weights. Do not sum +household values repeated on person records as if they were distinct households. +These measures do not by themselves establish a representative budget costing. + +## Worked household example + +For model year 2026, consider one 40-year-old London employee with £40,000 of +pre-response earnings, no pension contributions and no other supplied income. +Raise the employer rate from 15% to 16%, holding the other parameters fixed. Current +main includes the annual secondary-threshold correction: £5,000 rather than the +old rounded-weekly equivalent of £4,992. Each comparison below applies the same +incidence assumptions to its baseline and reform. + +Annual changes in pounds, rounded to two decimal places: + +| Input and incidence assumptions | Employer NI | Income tax | Employee NI | Gross pay | Government balance | Household net income | +|---|---:|---:|---:|---:|---:|---:| +| Components; employee 1, consumer/capital 0.5 | +301.72 | −60.34 | −24.14 | −301.72 | +217.24 | −217.24 | +| Components; employee 0, consumer/capital 0.5 | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | +| Components; all three zero | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | +| Pay set after construction; employee 1 | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | + +At fixed pay, the extra liability is `0.01 × (40,000 − 5,000) = £350`. +With full wage incidence, the baseline employer cost is £45,250. Solving +`pay + 0.16 × (pay − 5,000) = 45,250` gives pay of approximately £39,698.28. +The income-tax and employee-NI changes partly offset the wage fall. The last three +rows coincide in this example; they do not imply identical population incidence. + +Run this snippet from a checkout's supported Python environment: + +```python +from policyengine_core.reforms import Reform +from policyengine_uk import Simulation + +YEAR = 2026 +SPAN = "2026-01-01.2026-12-31" +INCIDENCE = "gov.contrib.policyengine.employer_ni." +RATE = "gov.hmrc.national_insurance.class_1.rates.employer" +VARIABLES = [ + "ni_employer", "income_tax", "ni_employee", "employment_income", + "gov_balance", "household_net_income", +] + + +def result(employee, consumer, capital, direct_pay=False): + pay_input = "employment_income" if direct_pay else "employment_income_before_lsr" + situation = { + "people": {"worker": {"age": {YEAR: 40}, pay_input: {YEAR: 40_000}}}, + "benunits": {"family": {"members": ["worker"]}}, + "households": {"home": {"members": ["worker"], "region": {YEAR: "LONDON"}}}, + } + assumptions = { + INCIDENCE + "employee_incidence": {SPAN: employee}, + INCIDENCE + "consumer_incidence": {SPAN: consumer}, + INCIDENCE + "capital_incidence": {SPAN: capital}, + } + baseline = Simulation( + situation=situation, + reform=Reform.from_dict(assumptions, country_id="uk"), + ) + reform = Simulation( + situation=situation, + reform=Reform.from_dict({**assumptions, RATE: {SPAN: 0.16}}, country_id="uk"), + ) + if direct_pay: + # UK construction moves situation earnings to the pre-response input. + # Explicitly supply the final earnings holder after construction. + baseline.set_input("employment_income", YEAR, [40_000]) + reform.set_input("employment_income", YEAR, [40_000]) + before = {v: float(baseline.calculate(v, YEAR).sum()) for v in VARIABLES} + after = {v: float(reform.calculate(v, YEAR).sum()) for v in VARIABLES} + return { + "baseline": before, "reform": after, + "delta": {v: after[v] - before[v] for v in VARIABLES}, + "residual_channels": { + v: float(reform.calculate(v, YEAR).sum()) for v in ( + "employer_ni_response_consumer_incidence", + "employer_ni_response_capital_incidence", + ) + }, + } + + +rows = { + "Employee 1; consumer/capital 0.5": result(1, 0.5, 0.5), + "Employee 0; consumer/capital 0.5": result(0, 0.5, 0.5), + "All three zero": result(0, 0, 0), + "Pay set after construction; employee 1": result(1, 0.5, 0.5, direct_pay=True), +} +for label, row in rows.items(): + print(label, {v: round(value, 2) for v, value in row["delta"].items()}) +``` + +The residual-channel outputs are retained in `rows` for inspection. No private +microdata, representative population estimate or empirical calibration is used. + +## Comparing an official costing + +Match the definition and horizon of the specific official figure before comparing +it with a model result. For example, the +[OBR's October 2024 Economic and fiscal outlook](https://obr.uk/efo/economic-and-fiscal-outlook-october-2024/) +reported £25.7 billion of employer-NICs revenue in 2029–30 before indirect economic +effects (overview paragraph 1.7). Its paragraph 2.14 assumed about 60% of the cost +would pass to employees in the short term, with more adjustment later through +prices and nominal wages. These are dated forecast assumptions, not evidence for +the model's defaults. Other official headlines can use different definitions. diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md index 079c942a05..fecfda74b4 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md @@ -1 +1,23 @@ -# Employer NI +# Employer NI incidence + +These parameters describe how the model allocates a change in employer National +Insurance costs. Their defaults, unchanged from 2010, are employee incidence `1`, +consumer incidence `0.5` and capital incidence `0.5`. They are adjustable modeling +assumptions; no empirical estimate is claimed for the defaults. + +For eligible earnings computed from components, employee incidence `1` applies the +computed fixed-employer-cost pay adjustment in the same model year. This changes +income tax and employee NI as well as gross pay. A supplied final +`employment_income` holder overrides that formula. The UK `Simulation` constructor +moves situation earnings into `employment_income_before_lsr`, so a constructor +field alone does not demonstrate that override. + +Setting employee incidence to `0` leaves the consumer and capital channels active +where population weights and exposure totals support them. Set all three incidence +parameters to `0` to disable these three channels. Other labor-supply responses +have separate controls. + +Report employer-NI liability, net `gov_balance` and household income effects +separately. The [assumptions page](../../../../../../docs/book/assumptions/employer-ni-incidence.md) +explains the implemented guards, aggregation, current worked example and a dated +OBR comparison. A single-household example does not establish population incidence. diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml index edb1a27015..bf1ed0685d 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of (remaining after employee incidence) employer NI that is borne by capital. +description: >- + Share of the remaining modeled employer-cost change allocated to capital after employee incidence. The default is 0.5. This channel is inactive at employee incidence 1 or capital incidence 0; otherwise it uses population person weights and household corporate wealth shares, with zero returned when the dataset attribute is absent or total wealth is zero. The amount enters household_tax. Set all three incidence parameters to 0 to disable these three channels. No empirical estimate is claimed for this default. values: 2010-01-01: 0.5 metadata: label: Employer NI capital incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_capital_incidence.py diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml index 765298c718..dccb0d44b9 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of (remaining after employee incidence) employer NI that is borne by consumers. +description: >- + Share of the remaining modeled employer-cost change allocated to consumers after employee incidence. The default is 0.5. This channel is inactive at employee incidence 1 or consumer incidence 0; otherwise it uses population person weights and household consumption shares, with zero returned when the dataset attribute is absent or total consumption is zero. The amount enters household_tax. Set all three incidence parameters to 0 to disable these three channels. No empirical estimate is claimed for this default. values: 2010-01-01: 0.5 metadata: label: Employer NI consumer incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_consumer_incidence.py diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml index b0aaf7efeb..7ca09ae21b 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of employer NI that is borne by employees. +description: >- + Share of the computed fixed-employer-cost pay change passed to employees in the same model year. The default of 1 applies the full adjustment to eligible earnings computed from components; 0 disables this wage channel. A supplied final employment_income holder overrides that formula, while UK Simulation construction remaps situation earnings to employment_income_before_lsr. The consumer and capital assumptions allocate remaining modeled employer-cost changes. This is a modeling assumption, not an empirically estimated default. values: 2010-01-01: 1 metadata: label: Employer NI employee incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py From 2e16ce0b60386329905f5321972641b0cc028c3f 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 04:29:23 +0100 Subject: [PATCH 101/224] Format employer NI documentation example --- docs/book/assumptions/employer-ni-incidence.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/book/assumptions/employer-ni-incidence.md b/docs/book/assumptions/employer-ni-incidence.md index b5021aa515..78fadb32a6 100644 --- a/docs/book/assumptions/employer-ni-incidence.md +++ b/docs/book/assumptions/employer-ni-incidence.md @@ -114,8 +114,12 @@ SPAN = "2026-01-01.2026-12-31" INCIDENCE = "gov.contrib.policyengine.employer_ni." RATE = "gov.hmrc.national_insurance.class_1.rates.employer" VARIABLES = [ - "ni_employer", "income_tax", "ni_employee", "employment_income", - "gov_balance", "household_net_income", + "ni_employer", + "income_tax", + "ni_employee", + "employment_income", + "gov_balance", + "household_net_income", ] @@ -147,10 +151,12 @@ def result(employee, consumer, capital, direct_pay=False): before = {v: float(baseline.calculate(v, YEAR).sum()) for v in VARIABLES} after = {v: float(reform.calculate(v, YEAR).sum()) for v in VARIABLES} return { - "baseline": before, "reform": after, + "baseline": before, + "reform": after, "delta": {v: after[v] - before[v] for v in VARIABLES}, "residual_channels": { - v: float(reform.calculate(v, YEAR).sum()) for v in ( + v: float(reform.calculate(v, YEAR).sum()) + for v in ( "employer_ni_response_consumer_incidence", "employer_ni_response_capital_incidence", ) From f72a29be5f4fcafd642c352defe11b840382f5ae 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 23:33:26 +0100 Subject: [PATCH 102/224] Fix issues from review: shorten employer NI incidence descriptions --- .../gov/contrib/policyengine/employer_ni/capital_incidence.yaml | 2 +- .../contrib/policyengine/employer_ni/consumer_incidence.yaml | 2 +- .../contrib/policyengine/employer_ni/employee_incidence.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml index bf1ed0685d..89b8d73a41 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml @@ -1,5 +1,5 @@ description: >- - Share of the remaining modeled employer-cost change allocated to capital after employee incidence. The default is 0.5. This channel is inactive at employee incidence 1 or capital incidence 0; otherwise it uses population person weights and household corporate wealth shares, with zero returned when the dataset attribute is absent or total wealth is zero. The amount enters household_tax. Set all three incidence parameters to 0 to disable these three channels. No empirical estimate is claimed for this default. + Share of the remaining employer-cost change allocated to capital after employee incidence; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 0.5 metadata: diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml index dccb0d44b9..f23aab2df3 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml @@ -1,5 +1,5 @@ description: >- - Share of the remaining modeled employer-cost change allocated to consumers after employee incidence. The default is 0.5. This channel is inactive at employee incidence 1 or consumer incidence 0; otherwise it uses population person weights and household consumption shares, with zero returned when the dataset attribute is absent or total consumption is zero. The amount enters household_tax. Set all three incidence parameters to 0 to disable these three channels. No empirical estimate is claimed for this default. + Share of the remaining employer-cost change allocated to consumers after employee incidence; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 0.5 metadata: diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml index 7ca09ae21b..4925deb028 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml @@ -1,5 +1,5 @@ description: >- - Share of the computed fixed-employer-cost pay change passed to employees in the same model year. The default of 1 applies the full adjustment to eligible earnings computed from components; 0 disables this wage channel. A supplied final employment_income holder overrides that formula, while UK Simulation construction remaps situation earnings to employment_income_before_lsr. The consumer and capital assumptions allocate remaining modeled employer-cost changes. This is a modeling assumption, not an empirically estimated default. + Share of the fixed-employer-cost pay change passed to employees in the same model year; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 1 metadata: From d7ed2889a7bc94d69c6ca10aa8f1b1296643293c Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 13:13:31 +0200 Subject: [PATCH 103/224] 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 104/224] 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 105/224] 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 106/224] 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 107/224] 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 108/224] 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 109/224] 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 110/224] 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 508fbcc42806abebe423c660eb4b4f79fc48c930 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 14:46:54 +0200 Subject: [PATCH 111/224] 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 112/224] =?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 113/224] 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 114/224] 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 115/224] 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 116/224] 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 117/224] 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 118/224] 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 119/224] 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 bbb12c043a60165ade0d41f7f2cd9de19e32e5dc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 15:58:05 -0400 Subject: [PATCH 120/224] Count IIDB, SDA, incapacity benefit and maternity allowance in Pension Credit income SPC Regs 2002 reg 15(1) prescribes all social security benefits as income except a closed list that does not name these four. Append them to gov.dwp.pension_credit.guarantee_credit.income. The reported IIDB amount cannot separate the constant attendance and exceptionally severe disablement increases that reg 15(1)(c) and (e) except. Complete the reg 9 Savings Credit non-qualifying list with working tax credit, incapacity benefit, SDA and maternity allowance, and let savings_credit_income read benefit-unit sources such as working tax credit. Fix the list's unit metadata and add a label. Housing Benefit income counts IIDB, SDA and incapacity benefit under both HB (SPC) Regs 2006 reg 29(1)(j) and HB Regs 2006 reg 40 with Sch 5. Remove the unused duplicate parameter gov.dwp.pension_credit.income.savings_credit_excluded_sources. Refs PolicyEngine/policyengine-uk#1954. Co-Authored-By: Claude Opus 5.5 --- ...ension-credit-prescribed-benefits.fixed.md | 1 + ...sion-credit-prescribed-benefits.removed.md | 1 + .../guarantee_credit/income.yaml | 7 + .../savings_credit_excluded_sources.yaml | 12 - .../savings_credit/excluded_income.yaml | 10 +- ...ion_credit_prescribed_benefits_income.yaml | 476 ++++++++++++++++++ .../housing_benefit_applicable_income.py | 9 + .../savings_credit/savings_credit_income.py | 14 +- 8 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 changelog.d/pension-credit-prescribed-benefits.fixed.md create mode 100644 changelog.d/pension-credit-prescribed-benefits.removed.md delete mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml diff --git a/changelog.d/pension-credit-prescribed-benefits.fixed.md b/changelog.d/pension-credit-prescribed-benefits.fixed.md new file mode 100644 index 0000000000..1717f0b768 --- /dev/null +++ b/changelog.d/pension-credit-prescribed-benefits.fixed.md @@ -0,0 +1 @@ +- Pension Credit income now counts industrial injuries disablement benefit, severe disablement allowance, incapacity benefit and maternity allowance (SPC Regs 2002 reg 15(1)); Savings Credit treats working tax credit, incapacity benefit, severe disablement allowance and maternity allowance as non-qualifying income (reg 9); Housing Benefit income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit. diff --git a/changelog.d/pension-credit-prescribed-benefits.removed.md b/changelog.d/pension-credit-prescribed-benefits.removed.md new file mode 100644 index 0000000000..569194bb61 --- /dev/null +++ b/changelog.d/pension-credit-prescribed-benefits.removed.md @@ -0,0 +1 @@ +- Removed the unused duplicate parameter `gov.dwp.pension_credit.income.savings_credit_excluded_sources`; `gov.dwp.pension_credit.savings_credit.excluded_income` is the list the model reads. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index 3da4354bd1..ec4fa71901 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -26,3 +26,10 @@ values: # Supplement, which is a separate variable. - carers_allowance - carer_support_payment + # Reg. 15(1) does not list these either. Reg. 15(1)(c) and (e) except the + # constant attendance and exceptionally severe disablement increases of + # IIDB, but iidb is the whole reported payment and cannot separate them. + - iidb + - sda + - incapacity_benefit + - maternity_allowance diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml deleted file mode 100644 index bedb5dd94b..0000000000 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml +++ /dev/null @@ -1,12 +0,0 @@ -description: Income sources which are deducted from income before Savings Credit calculations. -metadata: - economy: false - propagate_metadata_to_children: true - reference: - - href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/9 - title: The State Pension Credit Regulations 2002 reg. 9 - unit: currency-GBP -values: - 2002-01-01: - - jsa_contrib - - esa_contrib diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml index e6fe669bbd..145e714047 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml @@ -1,12 +1,20 @@ description: Income sources which are deducted from Guarantee Credit income before Savings Credit calculations. metadata: + label: Savings Credit non-qualifying income sources propagate_metadata_to_children: true reference: - href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/9 title: The State Pension Credit Regulations 2002 reg. 9 - unit: currency-GBP + unit: list values: 2002-01-01: + # Reg. 9(c) and (g). - jsa_contrib - esa_contrib + # Reg. 9(a), (b), (d) and (e). Reg. 9(f) maintenance is not modelled: + # maintenance_income does not separate spousal from child maintenance. + - working_tax_credit + - incapacity_benefit + - sda + - maternity_allowance diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml new file mode 100644 index 0000000000..082c1d1318 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml @@ -0,0 +1,476 @@ +# Industrial injuries disablement benefit (IIDB), severe disablement allowance +# (SDA), incapacity benefit (IB) and maternity allowance (MA) in Pension Credit +# and Housing Benefit income. +# +# SPC Regs 2002 reg 15(1): "all social security benefits are prescribed except" +# a closed list. The list does not name the four benefits; for IIDB it excepts +# only the increases under SSCBA 1992 ss.104 and 105 (constant attendance and +# exceptionally severe disablement) and Sch 8 paras 4 and 7(2)(b) (reg +# 15(1)(c)-(e)). Reg 9 makes WTC, IB, SDA and MA (with contribution-based JSA, +# contributory ESA and reg 15(5)(d) maintenance) non-qualifying income for +# Savings Credit; IIDB stays qualifying income. HB (SPC) Regs 2006 reg 29(1)(j) +# counts the same social security benefits for pension-age Housing Benefit, and +# HB Regs 2006 reg 40 counts working-age gross income less Sch 5. +# +# The awards are ordinary awards with no excepted increase, entered as reported +# amounts (SDA as a reported award, which the model pays at gov.dwp.sda.maximum). +# Weekly rates, x 52: +# SI 2026/148 Sch 1: IIDB 100% 233.90, 20% 46.78 (Part V); SDA 103.85 plus +# the higher age related addition 15.50 (Part III); long-term IB 146.60 +# (Part I). Art 9: SMP reg 6 rate 194.32, which is the MA standard rate under +# SSCBA s.35A(1)(b) from 6 April 2026 (art 1). Art 29: standard minimum +# guarantee 238.00 single and 363.25 couple; Savings Credit threshold 208.07 +# single. +# SI 2025/295 Sch 1 Part V: IIDB 20% 45.06; art 29: single standard minimum +# guarantee 227.10. +# MA and IB are rarely paid above State Pension age (the Enhanced FRS has no +# pension-age recipients); the cases test classification. None of these +# claimants pays income tax, so tax does not enter any expected value. + +- name: Case 1, single pensioner with IIDB at 20 percent. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 2,432.56 = 3,432.56. + pension_credit_income: 1_000 + 46.78 * 52 + # 12,376 - 3,432.56 = 8,943.44. + guarantee_credit: 238 * 52 - (1_000 + 46.78 * 52) + +- name: Case 2, pensioner couple whose partner has IIDB at 100 percent. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 12,162.80 = 17,162.80. + pension_credit_income: 5_000 + 233.90 * 52 + # 18,889 - 17,162.80 = 1,726.20. + guarantee_credit: 363.25 * 52 - (5_000 + 233.90 * 52) + +- name: Case 3, single pensioner with SDA and the higher age related addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + sda_reported: 1 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # The model pays 119.35 a week, the statutory 103.85 + 15.50. + sda: (103.85 + 15.50) * 52 + # 1,000 + 6,206.20 = 7,206.20. + pension_credit_income: 1_000 + (103.85 + 15.50) * 52 + # 12,376 - 7,206.20 = 5,169.80. + guarantee_credit: 238 * 52 - (1_000 + (103.85 + 15.50) * 52) + +- name: Case 4, pensioner couple whose partner has SDA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 6,206.20 = 11,206.20. + pension_credit_income: 5_000 + (103.85 + 15.50) * 52 + # 18,889 - 11,206.20 = 7,682.80. + guarantee_credit: 363.25 * 52 - (5_000 + (103.85 + 15.50) * 52) + +- name: Case 5, single pensioner with long-term incapacity benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # IB is taxable, but 8,623.20 of income bears no tax in the model. + income_tax: 0 + # 1,000 + 7,623.20 = 8,623.20. + pension_credit_income: 1_000 + 146.60 * 52 + # 12,376 - 8,623.20 = 3,752.80. + guarantee_credit: 238 * 52 - (1_000 + 146.60 * 52) + +- name: Case 6, pensioner couple whose partner has long-term incapacity benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + income_tax: [0, 0] + # 5,000 + 7,623.20 = 12,623.20. + pension_credit_income: 5_000 + 146.60 * 52 + # 18,889 - 12,623.20 = 6,265.80. + guarantee_credit: 363.25 * 52 - (5_000 + 146.60 * 52) + +- name: Case 7, single pensioner with maternity allowance at the standard rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 10,104.64 = 11,104.64. + pension_credit_income: 1_000 + 194.32 * 52 + # 12,376 - 11,104.64 = 1,271.36. + guarantee_credit: 238 * 52 - (1_000 + 194.32 * 52) + +- name: Case 8, pensioner couple whose partner has maternity allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 10,104.64 = 15,104.64. + pension_credit_income: 5_000 + 194.32 * 52 + # 18,889 - 15,104.64 = 3,784.36. + guarantee_credit: 363.25 * 52 - (5_000 + 194.32 * 52) + +- name: Case 9, single pensioner with IIDB at 20 percent in 2025. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 45.06 * 52 + benunits: + benunit: + members: [person1] + # SI 2025/295 art 29 sets the single standard minimum guarantee at + # 227.10; the model's 2025 parameter holds 221.85, so the statutory + # amount is supplied to isolate the income rule. + minimum_guarantee: 227.10 * 52 + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 2,343.12 = 3,343.12. + pension_credit_income: 1_000 + 45.06 * 52 + # 11,809.20 - 3,343.12 = 8,466.08. + guarantee_credit: 227.10 * 52 - (1_000 + 45.06 * 52) + +- name: Case 10, IIDB is qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + # Aged 80, so State Pension age was reached before the 2016 cutoff. + age: 80 + state_pension: 0 + private_pension_income: 175.04 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 175.04 + 46.78 = 221.82 a week, 11,534.64 a year. + pension_credit_income: (175.04 + 46.78) * 52 + savings_credit_income: (175.04 + 46.78) * 52 + # 0.6 x (221.82 - 208.07) = 8.25 a week, 429.00 a year: below the + # maximum 0.6 x (238 - 208.07), and income is below the minimum + # guarantee, so nothing is withdrawn. + savings_credit: 0.6 * (221.82 - 208.07) * 52 + # (238 - 221.82) x 52 = 841.36. + guarantee_credit: (238 - 221.82) * 52 + +- name: Case 11, an IIDB recipient who reached State Pension age after the cutoff gets no Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 175.04 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + meets_savings_credit_age_requirement: false + savings_credit_income: (175.04 + 46.78) * 52 + savings_credit: 0 + guarantee_credit: (238 - 221.82) * 52 + +- name: Case 12, SDA is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 6_000 + sda_reported: 1 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 6,000 + 6,206.20 = 12,206.20. + pension_credit_income: 6_000 + (103.85 + 15.50) * 52 + # Reg 9(d): qualifying income is the 6,000 pension, below the 10,819.64 + # threshold, so no Savings Credit. Counting SDA as qualifying income + # would give 0.6 x (12,206.20 - 10,819.64) = 831.94. + savings_credit_income: 6_000 + is_savings_credit_eligible: false + savings_credit: 0 + # 12,376 - 12,206.20 = 169.80. + guarantee_credit: 238 * 52 - (6_000 + (103.85 + 15.50) * 52) + +- name: Case 13, incapacity benefit is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 4_500 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 12,123.20 of income bears no tax in the model. + income_tax: 0 + # 4,500 + 7,623.20 = 12,123.20. + pension_credit_income: 4_500 + 146.60 * 52 + # Reg 9(b). + savings_credit_income: 4_500 + savings_credit: 0 + # 12,376 - 12,123.20 = 252.80. + guarantee_credit: 238 * 52 - (4_500 + 146.60 * 52) + +- name: Case 14, maternity allowance is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 2_000 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 2,000 + 10,104.64 = 12,104.64. + pension_credit_income: 2_000 + 194.32 * 52 + # Reg 9(e). + savings_credit_income: 2_000 + savings_credit: 0 + # 12,376 - 12,104.64 = 271.36. + guarantee_credit: 238 * 52 - (2_000 + 194.32 * 52) + +- name: Case 15, working tax credit is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 10_000 + benunits: + benunit: + members: [person1] + # An illustrative award: reg 9(a) applies to any amount, and working + # tax credit rates are not set by the up-rating orders used here. + working_tax_credit: 1_000 + households: + household: + members: [person1] + country: ENGLAND + output: + # SPCA s.15(1)(b): 10,000 + 1,000 = 11,000. + pension_credit_income: 11_000 + # Reg 9(a): qualifying income is 10,000, below the 10,819.64 threshold. + # Counting the tax credit would give 0.6 x (11,000 - 10,819.64) = 108.22. + savings_credit_income: 10_000 + savings_credit: 0 + # 12,376 - 11,000 = 1,376. + guarantee_credit: 1_376 + +- name: Case 16, pension-age Housing Benefit counts IIDB once Guarantee Credit ends. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1] + # The model applies the HB earnings disregard (HB (SPC) Regs Sch 4) + # whether or not there are earnings; there are none here, so it is + # set to zero to isolate the income rule. + housing_benefit_applicable_income_disregard: 0 + households: + household: + members: [person1] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # 1,000 + 12,162.80 = 13,162.80, above the 12,376 minimum guarantee. + pension_credit_income: 1_000 + 233.90 * 52 + guarantee_credit: 0 + # HB (SPC) Regs reg 29(1)(j): the same 13,162.80. + housing_benefit_applicable_income: 1_000 + 233.90 * 52 + +- name: Case 17, working-age Housing Benefit counts incapacity benefit and SDA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + incapacity_benefit_reported: 146.60 * 52 + person2: + age: 45 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + housing_benefit_applicable_income_disregard: 0 + households: + household: + members: [person1, person2] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + income_tax: [0, 0] + # HB Regs 2006 reg 40(1): 7,623.20 + 6,206.20 = 13,829.40. + housing_benefit_applicable_income: (146.60 + 103.85 + 15.50) * 52 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index b97d9ac316..ad10cb9549 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -67,6 +67,15 @@ def formula(benunit, period, parameters): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", + # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), which + # excepts only the IIDB increases under SSCBA ss.104-105 and Sch 8 + # (heads (iii)-(v)), and HB Regs 2006 reg 40 with Sch 5, whose + # para 9 attendance allowance disregard covers the ss.104-105 + # increases (reg 2(1)). iidb is the whole reported payment and + # cannot separate those increases. + "iidb", + "sda", + "incapacity_benefit", ] INCOME_COMPONENTS = [ "employment_income", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py index 856e7e99f1..9cc98ec6fa 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py @@ -21,8 +21,14 @@ def formula(benunit, period, parameters): pc = parameters(period).gov.dwp.pension_credit person = benunit.members is_claimant_or_partner = person("is_claimant_or_partner", period) - excluded_income = benunit.sum( - add(person, period, pc.savings_credit.excluded_income) - * is_claimant_or_partner - ) + excluded_income = 0 + for source in pc.savings_credit.excluded_income: + # Person-level sources count for the claimant and partner only; + # benefit-unit sources (working tax credit) count once. + if person.entity.get_variable(source).entity.is_person: + excluded_income += benunit.sum( + person(source, period) * is_claimant_or_partner + ) + else: + excluded_income += add(benunit, period, [source]) return max_(0, guarantee_credit_income - excluded_income) From 2b2d51ff3a8ddaf3ca30349762eb60aba0b761f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:37:24 -0400 Subject: [PATCH 121/224] Finish builder edits after review (checkpoint) --- ...ion_credit_prescribed_benefits_income.yaml | 87 ++++- ...n_credit_prescribed_benefits_properties.py | 333 ++++++++++++++++++ .../housing_benefit_applicable_income.py | 13 +- 3 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml index 082c1d1318..5833bd54c2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml @@ -3,14 +3,15 @@ # and Housing Benefit income. # # SPC Regs 2002 reg 15(1): "all social security benefits are prescribed except" -# a closed list. The list does not name the four benefits; for IIDB it excepts -# only the increases under SSCBA 1992 ss.104 and 105 (constant attendance and +# a closed list. The list does not name the four benefits. For IIDB it excepts +# the increases under SSCBA 1992 ss.104 and 105 (constant attendance and # exceptionally severe disablement) and Sch 8 paras 4 and 7(2)(b) (reg -# 15(1)(c)-(e)). Reg 9 makes WTC, IB, SDA and MA (with contribution-based JSA, -# contributory ESA and reg 15(5)(d) maintenance) non-qualifying income for -# Savings Credit; IIDB stays qualifying income. HB (SPC) Regs 2006 reg 29(1)(j) -# counts the same social security benefits for pension-age Housing Benefit, and -# HB Regs 2006 reg 40 counts working-age gross income less Sch 5. +# 15(1)(c)-(e)); (h) excepts increases for a dependant other than a partner. +# Reg 9 makes WTC, IB, SDA and MA (with contribution-based JSA, contributory +# ESA and reg 15(5)(d) maintenance) non-qualifying income for Savings Credit; +# IIDB stays qualifying income. HB (SPC) Regs 2006 reg 29(1)(j) counts the same +# social security benefits for pension-age Housing Benefit, and HB Regs 2006 +# reg 40 counts working-age gross income less Sch 5. # # The awards are ordinary awards with no excepted increase, entered as reported # amounts (SDA as a reported award, which the model pays at gov.dwp.sda.maximum). @@ -20,12 +21,13 @@ # (Part I). Art 9: SMP reg 6 rate 194.32, which is the MA standard rate under # SSCBA s.35A(1)(b) from 6 April 2026 (art 1). Art 29: standard minimum # guarantee 238.00 single and 363.25 couple; Savings Credit threshold 208.07 -# single. +# single and 329.75 couple. # SI 2025/295 Sch 1 Part V: IIDB 20% 45.06; art 29: single standard minimum # guarantee 227.10. -# MA and IB are rarely paid above State Pension age (the Enhanced FRS has no -# pension-age recipients); the cases test classification. None of these -# claimants pays income tax, so tax does not enter any expected value. +# The cases test classification, not whether a pensioner would be paid MA or +# IB. None of these claimants pays income tax, so tax does not enter any +# expected value; the model taxes IB, so the IB cases also assert that its +# income tax is zero. - name: Case 1, single pensioner with IIDB at 20 percent. period: 2026 @@ -463,6 +465,7 @@ benunits: benunit: members: [person1, person2] + # As in Case 16 (the working-age disregard is HB Regs 2006 Sch 4). housing_benefit_applicable_income_disregard: 0 households: household: @@ -474,3 +477,65 @@ income_tax: [0, 0] # HB Regs 2006 reg 40(1): 7,623.20 + 6,206.20 = 13,829.40. housing_benefit_applicable_income: (146.60 + 103.85 + 15.50) * 52 + +- name: Case 18, working tax credit does not add to a positive Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 221.82 * 52 + benunits: + benunit: + members: [person1] + # An illustrative award, as in Case 15. + working_tax_credit: 200 + households: + household: + members: [person1] + country: ENGLAND + output: + # 11,534.64 + 200 = 11,734.64. + pension_credit_income: 221.82 * 52 + 200 + # Reg 9(a): the pension only. + savings_credit_income: 221.82 * 52 + # 0.6 x (221.82 - 208.07) x 52 = 429.00; income is below the minimum + # guarantee, so nothing is withdrawn. Counting the tax credit would give + # 0.6 x (11,734.64 - 10,819.64) = 549.00. + savings_credit: 0.6 * (221.82 - 208.07) * 52 + # 12,376 - 11,734.64 = 641.36. + guarantee_credit: 238 * 52 - (221.82 * 52 + 200) + +- name: Case 19, a partner's SDA is not qualifying income for a couple's Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 12_000 + person2: + age: 78 + state_pension: 0 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 12,000 + 6,206.20 = 18,206.20. + pension_credit_income: 12_000 + (103.85 + 15.50) * 52 + # Reg 9(d): qualifying income is the 12,000 pension, below the couple + # threshold of 329.75 x 52 = 17,147. Counting the partner's SDA would give + # 0.6 x (18,206.20 - 17,147) = 635.52. + savings_credit_income: 12_000 + is_savings_credit_eligible: false + savings_credit: 0 + # 18,889 - 18,206.20 = 682.80. + guarantee_credit: 363.25 * 52 - (12_000 + (103.85 + 15.50) * 52) diff --git a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py new file mode 100644 index 0000000000..ad6f252a14 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -0,0 +1,333 @@ +"""Prescribed benefits in Pension Credit and Housing Benefit income. + +The State Pension Credit Regulations 2002 reg 15(1) prescribe "all social +security benefits" as income except a closed list. The list does not name +industrial injuries disablement benefit (IIDB), severe disablement allowance +(SDA), incapacity benefit (IB) or maternity allowance (MA). For IIDB it +excepts the constant attendance and exceptionally severe disablement +increases (reg 15(1)(c)-(e)), which the reported IIDB amount cannot separate. +Pension-age Housing Benefit counts the same benefits under HB (SPC) Regs 2006 +reg 29(1)(j), whose exceptions for these benefits match reg 15(1)'s. Reg 9 +makes working tax credit, IB, SDA, MA, contribution-based JSA and contributory +ESA non-qualifying income for Savings Credit; IIDB stays qualifying income. + +Draws: single people and couples over State Pension age in England, Wales or +Scotland, renting from the council in 2026, with State Pension, a private +pension and any of the four benefits (each on a drawn member, at a drawn +amount). Some families also have a carer receiving Carer's Allowance or, in +Scotland, Carer Support Payment, so the sources #1952 added are covered +together with these. There are no earnings, pension contributions or +children, and capital is at most £10,000, so neither Pension Credit deemed +income nor Housing Benefit tariff income arises. The benefit amounts are +inputs standing for actual awards, independent of the model's rate formulas. + +Invariants: + +1. Differential: Pension Credit and pension-age Housing Benefit assess the + same income. Where Guarantee Credit is not paid and Housing Benefit income + is above its zero floor, Housing Benefit applicable income plus its income + disregard equals Pension Credit income. The disregard is added back because + the model applies the HB earnings disregard (HB (SPC) Regs Sch 4) whether or + not there are earnings; there are none here. There is no other intended + difference on this domain: both measures deduct the claimant's and + partner's income tax and National Insurance, and neither counts the + Scottish Carer Supplement (SPC Regs reg 15(1)(ri); HB (SPC) Regs reg + 29(1)(j)(xviiha)). +2. Guarantee Credit is non-increasing in each of the four benefits, and while + it is positive it falls pound for pound with the extra Pension Credit + income, which is the extra benefit less the extra income tax and National + Insurance (the model taxes IB). +3. Savings Credit income never exceeds Pension Credit income. Adding an amount + of a reg 9 source (working tax credit, IB, SDA, MA, contribution-based JSA + or contributory ESA) changes Savings Credit income only through income tax + and National Insurance, and never raises Savings Credit. Draws for this + invariant are aged 77 or over: the model dates State Pension age as birth + year plus the current State Pension age (66 in 2026), so they meet its + test of reaching it before the 2016 Savings Credit cutoff. +4. Boundary: on the private-pension step where Guarantee Credit ends, + 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. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PENSIONS = np.arange(0, 25_001, 500) +AMOUNTS = np.arange(0, 15_001, 1_250) +PRESCRIBED_BENEFITS = ("iidb", "sda", "incapacity_benefit", "maternity_allowance") +# Reg 9 sources and the input each is set through: the tax base reads +# reported contribution-based JSA and contributory ESA. +REG_9_INPUTS = { + "working_tax_credit": "working_tax_credit", + "incapacity_benefit": "incapacity_benefit", + "sda": "sda", + "maternity_allowance": "maternity_allowance", + "jsa_contrib": "jsa_contrib_reported", + "esa_contrib": "esa_contrib_reported", +} +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = { + "ENGLAND": "NORTH_WEST", + "WALES": "WALES", + "SCOTLAND": "SCOTLAND", +} + + +def money(high, low=0): + return st.floats(low, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def family(draw, min_age=67, high=12_000, state_pension=15_000): + adults = [ + dict( + age=draw(st.integers(min_age, 95)), + state_pension=draw(money(state_pension)), + ) + for _ in range(draw(st.integers(1, 2))) + ] + members = st.integers(0, len(adults) - 1) + return dict( + adults=adults, + # Each benefit goes to one drawn member; an amount of zero is common. + benefits={ + benefit: (draw(members), draw(st.one_of(st.just(0.0), money(high)))) + for benefit in PRESCRIBED_BENEFITS + }, + # As in #1952, at most one carer, by caring hours or a reported award. + carer=draw(st.one_of(st.none(), members)), + by_hours=draw(st.booleans()), + country=draw(st.sampled_from(tuple(REGIONS))), + rent=draw(money(12_000)), + council_tax=draw(money(3_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + ) + + +def situation(rows): + """One benefit unit and household per (family, private pension, inputs) row. + + The inputs map a variable to an amount added to the family's first adult, + or set for the benefit unit when the variable is working tax credit. + """ + people, benunits, households = {}, {}, {} + for r, (fam, pension, inputs) in enumerate(rows): + names = [] + for j, a in enumerate(fam["adults"]): + name = f"p{r}_{j}" + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a["state_pension"]}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(pension) * (j == 0)}, + } + for benefit, (recipient, amount) in fam["benefits"].items(): + person[benefit] = {YEAR: amount * (j == recipient)} + if j == fam["carer"]: + if fam["by_hours"]: + person["care_hours"] = {YEAR: 35} + else: + person["carers_allowance_reported"] = {YEAR: 1} + if j == 0: + for variable, amount in inputs.items(): + if variable != "working_tax_credit": + drawn = person.get(variable, {YEAR: 0.0})[YEAR] + person[variable] = {YEAR: drawn + float(amount)} + people[name] = person + names.append(name) + benunit = {"members": names} + if "working_tax_credit" in inputs: + benunit["working_tax_credit"] = {YEAR: float(inputs["working_tax_credit"])} + benunits[f"b{r}"] = benunit + households[f"h{r}"] = { + "members": names, + "country": {YEAR: fam["country"]}, + "region": {YEAR: REGIONS[fam["country"]]}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: fam["rent"]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(rows, shape, variables): + """Each variable per benefit unit (summed over members), reshaped.""" + simulation = Simulation(situation=situation(rows)) + return { + variable: np.asarray( + simulation.calculate(variable, YEAR, map_to="benunit") + ).reshape(shape) + for variable in variables + } + + +def pension_grid(families, variables): + rows = [(fam, pension, {}) for fam in families for pension in PENSIONS] + return calculate(rows, (len(families), len(PENSIONS)), variables) + + +def with_amount(fam, benefit, amount): + """The family with one benefit's award set to amount, on its recipient.""" + recipient, _ = fam["benefits"][benefit] + return {**fam, "benefits": {**fam["benefits"], benefit: (recipient, amount)}} + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( + families, +): + g = pension_grid( + families, + [ + "guarantee_credit", + "pension_credit_income", + "housing_benefit_eligible", + "housing_benefit_applicable_income", + "housing_benefit_applicable_income_disregard", + ], + ) + hb_income = g["housing_benefit_applicable_income"] + # Where Guarantee Credit is not paid, and away from the zero floor on + # Housing Benefit income. + compared = ( + (g["guarantee_credit"] <= 0) + & g["housing_benefit_eligible"].astype(bool) + & (hb_income > 0) + ) + before_disregard = hb_income + g["housing_benefit_applicable_income_disregard"] + pc_income = g["pension_credit_income"] + assert np.allclose(before_disregard[compared], pc_income[compared], atol=0.01), ( + families, + before_disregard[compared], + pc_income[compared], + ) + + +@PROPERTY_SETTINGS +@given(st.lists(family(state_pension=8_000), min_size=1, max_size=4)) +def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( + families, +): + rows = [ + (with_amount(fam, benefit, amount), 0, {}) + for fam in families + for benefit in PRESCRIBED_BENEFITS + for amount in AMOUNTS + ] + shape = (len(families), len(PRESCRIBED_BENEFITS), len(AMOUNTS)) + g = calculate( + rows, + shape, + [ + "guarantee_credit", + "pension_credit_income", + "income_tax", + "national_insurance", + ], + ) + gc = g["guarantee_credit"] + pc_income = g["pension_credit_income"] + tax = g["income_tax"] + g["national_insurance"] + assert np.all(np.diff(gc, axis=-1) <= 0.01), (families, gc) + # The extra Pension Credit income is the extra benefit less the extra tax. + extra_income = np.diff(pc_income, axis=-1) + positive_income = (pc_income[..., :-1] > 0) & (pc_income[..., 1:] > 0) + assert np.allclose( + extra_income[positive_income], + (np.diff(AMOUNTS) - np.diff(tax, axis=-1))[positive_income], + atol=0.01, + ), (families, pc_income, tax) + # While Guarantee Credit is paid, it falls by exactly that much. + paid = gc[..., 1:] > 0 + assert np.allclose(-np.diff(gc, axis=-1)[paid], extra_income[paid], atol=0.01), ( + families, + gc, + pc_income, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + family(min_age=77, high=3_000, state_pension=4_000), min_size=2, max_size=4 + ), + # Private pensions that put qualifying income near the Savings Credit + # window, so that Savings Credit is often payable before the addition. + st.lists(money(18_000, low=8_000), min_size=4, max_size=4), + money(15_000), +) +def test_reg_9_sources_change_savings_credit_income_only_through_tax( + families, pensions, amount +): + rows = [ + (fam, pension, {REG_9_INPUTS[source]: added}) + for fam, pension in zip(families, pensions) + for source in REG_9_INPUTS + for added in (0, amount) + ] + shape = (len(families), len(REG_9_INPUTS), 2) + g = calculate( + rows, + shape, + [ + "pension_credit_income", + "savings_credit_income", + "savings_credit", + "income_tax", + "national_insurance", + ], + ) + sc_income = g["savings_credit_income"] + assert np.all(sc_income <= g["pension_credit_income"] + 0.01), (families, g) + savings_credit = g["savings_credit"] + assert np.all(savings_credit[..., 1] <= savings_credit[..., 0] + 0.01), ( + families, + amount, + savings_credit, + ) + tax = g["income_tax"] + g["national_insurance"] + extra_tax = tax[..., 1] - tax[..., 0] + assert np.allclose( + sc_income[..., 1], np.maximum(sc_income[..., 0] - extra_tax, 0), atol=0.01 + ), (families, amount, sc_income, tax) + + +@PROPERTY_SETTINGS +@given(st.lists(family(high=4_000, state_pension=4_000), min_size=1, max_size=4)) +def test_net_income_does_not_fall_where_guarantee_credit_ends(families): + g = pension_grid( + families, + [ + "guarantee_credit", + "household_net_income", + "tv_licence", + "housing_benefit", + "council_tax_benefit", + ], + ) + step = np.diff(g["household_net_income"] + g["tv_licence"], axis=1) + gc = g["guarantee_credit"] + ends = (gc[:, :-1] > 0) & (gc[:, 1:] <= 0) + for i, fam in enumerate(families): + for k in np.flatnonzero(ends[i]): + assert step[i, k] >= -0.01, ( + fam, + f"private pension {PENSIONS[k]} -> {PENSIONS[k + 1]}", + f"net income change {step[i, k]:.2f}", + f"HB {g['housing_benefit'][i, k]:.2f} -> " + f"{g['housing_benefit'][i, k + 1]:.2f}", + f"CTR {g['council_tax_benefit'][i, k]:.2f} -> " + f"{g['council_tax_benefit'][i, k + 1]:.2f}", + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index ad10cb9549..17e6697cb1 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -67,12 +67,13 @@ def formula(benunit, period, parameters): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", - # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), which - # excepts only the IIDB increases under SSCBA ss.104-105 and Sch 8 - # (heads (iii)-(v)), and HB Regs 2006 reg 40 with Sch 5, whose - # para 9 attendance allowance disregard covers the ss.104-105 - # increases (reg 2(1)). iidb is the whole reported payment and - # cannot separate those increases. + # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), whose + # exceptions touching these are the IIDB increases under SSCBA + # ss.104-105 and Sch 8 (heads (iii)-(v)) and non-partner dependant + # increases (head (viii)), and HB Regs 2006 reg 40 with Sch 5, + # whose para 9 attendance allowance disregard covers the + # ss.104-105 increases (reg 2(1)). iidb is the whole reported + # payment and cannot separate those increases. "iidb", "sda", "incapacity_benefit", From 4a5de76f30ddb83d469f9d3e4697f453539c5104 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 16:12:13 -0400 Subject: [PATCH 122/224] Count war pensions and AFCS payments in Pension Credit income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State Pension Credit Act 2002 s.15(1)(g) counts war disablement pensions and war widow's or widower's pensions, and SPC Regs reg 15(5)(aa) counts AFCS guaranteed income payments. Sch IV para 1 disregards £10 a week of them. Pension Credit income counted none of them. Add pension_credit_war_pension_income: the claimant's and partner's afcs and war_widows_pension, each person's less the weekly disregard (a new parameter, gov.dwp.pension_credit.income.war_pension_disregard), and put it in the Pension Credit income list. Add war_widows_pension and war_widows_pension_reported, counted next to bsp in the gross benefit, HBAI and spending totals, so moving FRS code 9 out of bsp_reported keeps those totals unchanged. Housing Benefit is not changed. Add YAML cases from 2026-27 statutory rates (SI 2026/148) and Hypothesis properties: the Pension Credit and Housing Benefit income differential, bounds and closed form, Guarantee Credit monotonicity and moving amounts between sources within a person. Co-Authored-By: Claude Opus 5.5 --- .../pension-credit-war-pensions.fixed.md | 1 + .../guarantee_credit/income.yaml | 3 + .../income/war_pension_disregard.yaml | 14 + .../pension_credit_war_pension_income.yaml | 332 ++++++++++++++++++ ...t_pension_credit_war_pension_properties.py | 263 ++++++++++++++ .../pension_credit_war_pension_income.py | 34 ++ .../variables/gov/dwp/war_widows_pension.py | 15 + 7 files changed, 662 insertions(+) create mode 100644 changelog.d/pension-credit-war-pensions.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml create mode 100644 policyengine_uk/tests/test_pension_credit_war_pension_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py diff --git a/changelog.d/pension-credit-war-pensions.fixed.md b/changelog.d/pension-credit-war-pensions.fixed.md new file mode 100644 index 0000000000..a88f758169 --- /dev/null +++ b/changelog.d/pension-credit-war-pensions.fixed.md @@ -0,0 +1 @@ +- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, less £10 a week for each person (State Pension Credit Regulations 2002 Sch. IV para. 1). Add a `war_widows_pension` input, counted in the benefit totals alongside `bsp`. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index 3da4354bd1..f641f998d6 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -14,6 +14,9 @@ values: 2002-01-01: - pension_credit_earnings - working_tax_credit + # War pensions (SPCA s.15(1)(g)) and AFCS guaranteed income payments + # (reg. 15(5)(aa)), each person's less £10 a week (Sch. IV para. 1). + - pension_credit_war_pension_income - state_pension - private_pension_income - property_income_after_finance_costs diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml new file mode 100644 index 0000000000..65a079f26f --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml @@ -0,0 +1,14 @@ +description: Weekly amount of a person's war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from income for Pension Credit. +values: + 2003-10-06: 10 +metadata: + unit: currency-GBP + period: week + label: Pension Credit war pension disregard + reference: + - title: State Pension Credit Regulations 2002 Sch. IV para. 1 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/1 + - title: State Pension Credit Regulations 2002 reg. 17(7) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/17 + - title: State Pension Credit Regulations 2002 reg. 1(1) (in force 6 October 2003) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/1 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml new file mode 100644 index 0000000000..3656a8c96c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -0,0 +1,332 @@ +# War pensions and Armed Forces Compensation Scheme (AFCS) payments in +# Pension Credit income. +# +# SPCA 2002 s.15(1)(g) counts "a war disablement pension or war widow's or +# widower's pension"; SPC Regs 2002 reg 15(5)(aa) counts "a guaranteed income +# payment". Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of any of +# the following": war disablement pensions, war widow's or widower's pensions +# and guaranteed income payments among them. PolicyEngine applies one £10 a +# week to each person's qualifying payments together (para 1's "£10 of any of +# the following" and para 11(3)(b)'s single "£10 ... disregarded in accordance +# with paragraph 1(a) to (f)"), and computes the claimant's and the partner's +# amounts separately (reg 14: "any reference in this Part to the claimant +# shall apply equally to any partner of the claimant"). Reg 9 does not exclude +# these payments from Savings Credit qualifying income. +# +# Statutory 2026-27 rates (SI 2026/148 art 29): standard minimum guarantee +# £238.00 single and £363.25 couple (reg 6(1)); severe disability addition +# £86.05 (reg 6(5)(a)); Savings Credit threshold £208.07 single (reg 7(2)). +# Reg 7(1): maximum savings credit and amount A 60%, amount B 40%. The £10 +# disregard has been £10 since the Regulations were made. Payment amounts +# are illustrative weekly amounts (war pensions and AFCS payments are uprated +# by Ministry of Defence instruments, not by SI 2026/148). Annual amounts use +# the model's 52 weeks a year. + +- name: Case 1, an AFCS payment above £10 a week counts less £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # (30 - 10) * 52 = 1,040. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + minimum_guarantee: 238 * 52 + # 238 * 52 - 1,040 = 12,376 - 1,040 = 11,336. + guarantee_credit: (238 - 20) * 52 + +- name: Case 2, an AFCS payment below £10 a week is wholly disregarded. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 5 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(5 - 10, 0) * 52 = 0. Unchanged behaviour: passes on the base too. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 3, an AFCS payment of exactly £10 a week is wholly disregarded. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 10 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(10 - 10, 0) * 52 = 0. Unchanged behaviour: passes on the base too. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 4, a war widow's pension counts less £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + war_widows_pension: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # (30 - 10) * 52 = 1,040; 12,376 - 1,040 = 11,336. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + guarantee_credit: (238 - 20) * 52 + +- name: Case 5, one person with an AFCS payment and a war widow's pension gets one £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 20 * 52 + war_widows_pension: 15 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # (20 + 15 - 10) * 52 = 25 * 52 = 1,300; 12,376 - 1,300 = 11,076. + pension_credit_war_pension_income: (20 + 15 - 10) * 52 + pension_credit_income: (20 + 15 - 10) * 52 + guarantee_credit: (238 - 25) * 52 + +- name: Case 6, each member of a couple gets their own £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + state_pension: 0 + afcs: 30 * 52 + person2: + age: 70 + state_pension: 0 + war_widows_pension: 20 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # ((30 - 10) + (20 - 10)) * 52 = 30 * 52 = 1,560. + pension_credit_war_pension_income: (20 + 10) * 52 + pension_credit_income: (20 + 10) * 52 + minimum_guarantee: 363.25 * 52 + # 363.25 * 52 - 1,560 = 18,889 - 1,560 = 17,329. + guarantee_credit: (363.25 - 30) * 52 + +- name: Case 7, a partner's unused disregard does not pass to the claimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + state_pension: 0 + afcs: 30 * 52 + person2: + age: 70 + state_pension: 0 + afcs: 5 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # max(30 - 10, 0) + max(5 - 10, 0) = 20 a week, not 30 + 5 - 20 = 15. + # 20 * 52 = 1,040; 18,889 - 1,040 = 17,849. + pension_credit_war_pension_income: 20 * 52 + pension_credit_income: 20 * 52 + guarantee_credit: (363.25 - 20) * 52 + +- name: Case 8, an AFCS payment is Savings Credit qualifying income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 198.07 * 52 + afcs: 40 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Private pension 10,299.64 is below the personal allowance and AFCS is + # not taxable income, so there is no income tax. + income_tax: 0 + # 198.07 + (40 - 10) = 228.07 a week; 228.07 * 52 = 11,859.64. + pension_credit_war_pension_income: (40 - 10) * 52 + pension_credit_income: 228.07 * 52 + savings_credit_income: 228.07 * 52 + # 238 - 228.07 = 9.93 a week; 9.93 * 52 = 516.36. + guarantee_credit: 9.93 * 52 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (228.07 - 208.07)) + # = min(17.958, 12.00) = 12.00; amount B = 0.4 * 0 = 0. + # 12.00 * 52 = 624. Whole pennies, so reg 7(4) rounding does not arise. + savings_credit: 12 * 52 + +- name: Case 9, a dependant's and a third adult's payments do not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 75 + state_pension: 0 + person2: + age: 72 + state_pension: 0 + person3: + age: 45 + afcs: 50 * 52 + person4: + age: 17 + afcs: 30 * 52 + benunits: + benunit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + country: ENGLAND + output: + is_claimant_or_partner: [true, true, false, false] + # Only the claimant's and partner's payments count (SPCA s.5; reg 14). + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + +- name: Case 10, armed forces independence payment is not Pension Credit income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 30 * 52 + armed_forces_independence_payment: 100 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Reg 15(1)(ab) excepts armed forces independence payment; only the AFCS + # payment counts: (30 - 10) * 52 = 1,040. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + # Armed forces independence payment gives the severe disability + # addition (Sch I para 1; reg 6(5)(a)): (238 + 86.05) * 52 = 16,850.60. + minimum_guarantee: (238 + 86.05) * 52 + # 16,850.60 - 1,040 = 15,810.60. + guarantee_credit: (238 + 86.05 - 20) * 52 + +- name: Case 11, Bereavement Support Payment is still not Pension Credit income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + bsp: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Reg 15(1)(n) excepts bereavement support payment. Unchanged behaviour: + # passes on the base too. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 12, a reported war widow's pension is in the benefit totals and untaxed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + war_widows_pension_reported: 30 * 52 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + country: ENGLAND + output: + war_widows_pension: 30 * 52 + # It joins the gross benefit totals that bsp is in, so moving FRS code 9 + # from bsp to this variable leaves the totals unchanged. + household_benefits: 30 * 52 + hbai_benefits: 30 * 52 + gov_spending: 30 * 52 + pre_budget_change_household_benefits: 30 * 52 + # ITEPA 2003 s.639: no income tax on war widow's pensions. + social_security_income: 0 + income_tax: 0 diff --git a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py new file mode 100644 index 0000000000..91ca7a23ed --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -0,0 +1,263 @@ +"""War pensions and AFCS payments in Pension Credit income. + +State Pension Credit Act 2002 s.15(1)(g) counts "a war disablement pension or +war widow's or widower's pension", and the State Pension Credit Regulations +2002 reg 15(5)(aa) count "a guaranteed income payment" of the Armed Forces +Compensation Scheme. Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of +any of the following". `pension_credit_war_pension_income` counts the +claimant's and partner's `afcs` and `war_widows_pension`, each person's less +£10 a week, and Pension Credit income includes it. + +Invariants, for single people and couples over State Pension age renting from +the council in 2026, with any mix of AFCS payments and war widow's pensions, +and with a carer receiving Carer's Allowance or Carer Support Payment in some +families (the sources #1952 added): + +1. Differential with Housing Benefit. Where Guarantee Credit is not paid and + neither income floor binds, Housing Benefit applicable income plus its + income disregard equals Pension Credit income less the counted war pension + income. The difference is intended: the Housing Benefit (Persons who have + attained the qualifying age for state pension credit) Regulations 2006 reg + 29(1)(e) and (g) count these payments less £10 (Sch 5 para 1), but the + Social Security Administration Act 1992 s.134(8) lets an authority modify + its scheme to disregard "the whole or part" of a prescribed war + disablement or war widow's pension, and `housing_benefit_applicable_income` + counts neither `afcs` nor `war_widows_pension`. That is an assumption that + the authority runs such a local scheme with a full disregard; it is not a + statement about any particular authority. The draws have no earnings, no + pension contributions and capital of at most £10,000, so neither tariff + income nor the earnings rules apply. +2. Bounds and closed form. The counted amount is between 0 and the gross + payments of the claimant and partner, and equals the sum over the claimant + and partner of max(0, AFCS + war widow's pension - £520 a year). +3. Monotone. Guarantee Credit does not rise as one person's AFCS payment + rises, and does not change while the payment is at or below £10 a week. +4. Metamorphic. Moving an amount between `afcs` and `war_widows_pension` + within one person changes neither Pension Credit income nor Guarantee + Credit: one £10 a week applies to each person's payments together. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +# Sch IV para 1: £10 a week. +DISREGARD = 10 * WEEKS +PENSIONS = np.arange(0, 20_001, 1_000) +# Weekly AFCS payments for invariant 3, through the disregard and beyond. +AFCS_GRID = np.arange(0, 60.01, 2.5) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = { + "ENGLAND": "NORTH_WEST", + "WALES": "WALES", + "SCOTLAND": "SCOTLAND", +} + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +def weekly_payment(): + """Annual amount of a weekly payment, often at or near £10 a week.""" + weekly = st.one_of( + st.just(0.0), + st.sampled_from([5.0, 9.99, 10.0, 10.01, 25.0, 80.0]), + money(150), + ) + return weekly.map(lambda w: w * WEEKS) + + +@st.composite +def adult(draw): + return dict( + age=draw(st.integers(67, 95)), + state_pension=draw(money(15_000)), + afcs=draw(weekly_payment()), + war_widows_pension=draw(weekly_payment()), + # Share of the person's payments moved to the other source in + # invariant 4. + share=draw(st.floats(0, 1)), + ) + + +@st.composite +def family(draw): + adults = [draw(adult()) for _ in range(draw(st.integers(1, 2)))] + return dict( + adults=adults, + # A carer in some families, by caring hours or a reported award. + carer=draw(st.one_of(st.none(), st.integers(0, len(adults) - 1))), + by_hours=draw(st.booleans()), + country=draw(st.sampled_from(tuple(REGIONS))), + rent=draw(money(12_000)), + council_tax=draw(money(3_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + private_pension=draw(money(15_000)), + ) + + +def person_inputs(fam, j, a, private_pension, afcs, war_widows_pension): + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a["state_pension"]}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(private_pension) * (j == 0)}, + "afcs": {YEAR: float(afcs)}, + "war_widows_pension": {YEAR: float(war_widows_pension)}, + } + if fam["carer"] == j: + if fam["by_hours"]: + person["care_hours"] = {YEAR: 35} + else: + person["carers_allowance_reported"] = {YEAR: 1} + return person + + +def household(fam, names): + return { + "members": names, + "country": {YEAR: fam["country"]}, + "region": {YEAR: REGIONS[fam["country"]]}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: fam["rent"]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + + +def simulate(rows): + """One Simulation with one benefit unit per row. + + Each row is (family, private pension, [(afcs, war widow's pension) per + adult]). Returns a function giving a benefit-unit array per variable. + """ + people, benunits, households = {}, {}, {} + for r, (fam, private_pension, payments) in enumerate(rows): + names = [] + for j, (a, (afcs, wwp)) in enumerate(zip(fam["adults"], payments)): + name = f"p{r}_{j}" + people[name] = person_inputs(fam, j, a, private_pension, afcs, wwp) + names.append(name) + benunits[f"b{r}"] = {"members": names} + households[f"h{r}"] = household(fam, names) + simulation = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + } + ) + + def get(variable): + return np.asarray(simulation.calculate(variable, YEAR, map_to="benunit")) + + return get + + +def drawn_payments(fam): + return [(a["afcs"], a["war_widows_pension"]) for a in fam["adults"]] + + +def pension_grid(families, payments=drawn_payments): + """Each family once per private pension on the PENSIONS grid.""" + rows = [(fam, pension, payments(fam)) for fam in families for pension in PENSIONS] + get = simulate(rows) + shape = (len(families), len(PENSIONS)) + return lambda variable: get(variable).reshape(shape) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_pension_credit_and_housing_benefit_differ_only_by_war_pensions( + families, +): + get = pension_grid(families) + gc = get("guarantee_credit") + hb_income = get("housing_benefit_applicable_income") + hb_eligible = get("housing_benefit_eligible").astype(bool) + pc_income = get("pension_credit_income") + war = get("pension_credit_war_pension_income") + before_disregard = hb_income + get("housing_benefit_applicable_income_disregard") + carer_benefit = get("carers_allowance") + get("carer_support_payment") + for i, fam in enumerate(families): + if fam["carer"] is not None: + assert np.all(carer_benefit[i] > 0), fam + compared = (gc[i] <= 0) & hb_eligible[i] & (hb_income[i] > 0) + compared &= pc_income[i] > 0 + assert np.allclose( + before_disregard[i][compared], + (pc_income[i] - war[i])[compared], + atol=0.01, + ), (fam, before_disregard[i][compared], pc_income[i][compared]) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_counted_war_pension_income_bounds_and_closed_form(families): + get = pension_grid(families) + war = get("pension_credit_war_pension_income") + gross = get("afcs") + get("war_widows_pension") + for i, fam in enumerate(families): + expected = sum( + max(0.0, a["afcs"] + a["war_widows_pension"] - DISREGARD) + for a in fam["adults"] + ) + assert np.all(war[i] >= 0), fam + assert np.all(war[i] <= gross[i] + 0.01), fam + assert np.allclose(war[i], expected, atol=0.01), (fam, war[i], expected) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_guarantee_credit_does_not_rise_with_an_afcs_payment(families): + # The first adult's war widow's pension is set to zero so that the grid + # is the whole of their payments; the partner keeps their drawn payments. + rows = [ + ( + fam, + fam["private_pension"], + [(weekly * WEEKS, 0.0)] + drawn_payments(fam)[1:], + ) + for fam in families + for weekly in AFCS_GRID + ] + gc = simulate(rows)("guarantee_credit").reshape(len(families), len(AFCS_GRID)) + within_disregard = AFCS_GRID <= 10 + for i, fam in enumerate(families): + assert np.all(np.diff(gc[i]) <= 0.01), (fam, gc[i]) + assert np.allclose(gc[i][within_disregard], gc[i][0], atol=0.01), ( + fam, + gc[i], + ) + + +def moved(fam): + """Each person's total payments, split between the sources by `share`.""" + return [ + ( + a["share"] * (a["afcs"] + a["war_widows_pension"]), + (1 - a["share"]) * (a["afcs"] + a["war_widows_pension"]), + ) + for a in fam["adults"] + ] + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_moving_payments_between_sources_within_a_person(families): + get = pension_grid(families) + get_moved = pension_grid(families, payments=moved) + for variable in ["pension_credit_income", "guarantee_credit"]: + original, after = get(variable), get_moved(variable) + for i, fam in enumerate(families): + assert np.allclose(original[i], after[i], atol=0.01), (fam, variable) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py new file mode 100644 index 0000000000..92e6bb890d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class pension_credit_war_pension_income(Variable): + label = "War pension income counted for Pension Credit" + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + documentation = ( + "The claimant's and partner's war disablement pensions, war widow's or " + "widower's pensions and Armed Forces Compensation Scheme payments " + "(afcs, which holds guaranteed income payments and war disablement " + "pensions), less the Sch. IV para. 1 disregard. One weekly disregard " + "applies to each person's payments together, and the claimant and " + "partner each have their own (reg. 14). Dependants' and other " + "benefit-unit members' payments do not count. Components that Sch. IV " + "paras 2 to 6 and 12 disregard in full, and reg. 15(5)(a) and (ab) " + "payments that count in full, are not separated in the data." + ) + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/14", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/15", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/1", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.pension_credit.income + person = benunit.members + payments = add(person, period, ["afcs", "war_widows_pension"]) + disregard = p.war_pension_disregard * WEEKS_IN_YEAR + counted = max_(payments - disregard, 0) + return benunit.sum(counted * person("is_claimant_or_partner", period)) diff --git a/policyengine_uk/variables/gov/dwp/war_widows_pension.py b/policyengine_uk/variables/gov/dwp/war_widows_pension.py index 762833d871..8bd972472a 100644 --- a/policyengine_uk/variables/gov/dwp/war_widows_pension.py +++ b/policyengine_uk/variables/gov/dwp/war_widows_pension.py @@ -5,7 +5,22 @@ class war_widows_pension(Variable): value_type = float entity = Person label = "War Widow's or Widower's Pension" + documentation = ( + "A war widow's or widower's pension as defined in State Pension Credit " + "Act 2002 s.17(1): a widow's, widower's or surviving civil partner's " + "pension or allowance granted in respect of a death due to service or " + "war injury. This is the gross payment. Amounts wholly disregarded by " + "State Pension Credit Regulations 2002 Sch. IV paras 2 to 6 and 12 " + "(for example a supplementary pension under article 23(2) of the " + "Service Pensions Order 2006) are not separated. Family Resources " + "Survey benefit code 9 belongs here; the datasets still add it to " + "bsp_reported (policyengine-uk-data#578, microcosm#1222)." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/17", + "https://www.legislation.gov.uk/ukpga/2003/1/section/639", + ) adds = ["war_widows_pension_reported"] From 5a006ab5bf95347ef26d7680c53108cee7a087fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:37:25 -0400 Subject: [PATCH 123/224] Finish builder edits after review (checkpoint) --- .../pension-credit-war-pensions.fixed.md | 2 +- .../guarantee_credit/income.yaml | 3 +- .../income/war_pension_disregard.yaml | 2 +- .../pension_credit_war_pension_income.yaml | 49 +++++++++++---- ...t_pension_credit_war_pension_properties.py | 59 +++++++++---------- .../pension_credit_war_pension_income.py | 23 ++++---- 6 files changed, 83 insertions(+), 55 deletions(-) diff --git a/changelog.d/pension-credit-war-pensions.fixed.md b/changelog.d/pension-credit-war-pensions.fixed.md index a88f758169..32de2fb3bd 100644 --- a/changelog.d/pension-credit-war-pensions.fixed.md +++ b/changelog.d/pension-credit-war-pensions.fixed.md @@ -1 +1 @@ -- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, less £10 a week for each person (State Pension Credit Regulations 2002 Sch. IV para. 1). Add a `war_widows_pension` input, counted in the benefit totals alongside `bsp`. +- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Add a `war_widows_pension` input, counted in the benefit totals alongside `bsp`. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index f641f998d6..67bf30db2b 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -15,7 +15,8 @@ values: - pension_credit_earnings - working_tax_credit # War pensions (SPCA s.15(1)(g)) and AFCS guaranteed income payments - # (reg. 15(5)(aa)), each person's less £10 a week (Sch. IV para. 1). + # (reg. 15(5)(aa)) of the claimant and partner, each payment less £10 a + # week (Sch. IV para. 1). - pension_credit_war_pension_income - state_pension - private_pension_income diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml index 65a079f26f..1f0a5066ac 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml @@ -1,4 +1,4 @@ -description: Weekly amount of a person's war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from income for Pension Credit. +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 income for Pension Credit. values: 2003-10-06: 10 metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml index 3656a8c96c..fef73ee0e7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -5,13 +5,13 @@ # widower's pension"; SPC Regs 2002 reg 15(5)(aa) counts "a guaranteed income # payment". Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of any of # the following": war disablement pensions, war widow's or widower's pensions -# and guaranteed income payments among them. PolicyEngine applies one £10 a -# week to each person's qualifying payments together (para 1's "£10 of any of -# the following" and para 11(3)(b)'s single "£10 ... disregarded in accordance -# with paragraph 1(a) to (f)"), and computes the claimant's and the partner's -# amounts separately (reg 14: "any reference in this Part to the claimant -# shall apply equally to any partner of the claimant"). Reg 9 does not exclude -# these payments from Savings Credit qualifying income. +# and guaranteed income payments among them. PolicyEngine reads "£10 of any of +# the following" as £10 of each qualifying payment: afcs and +# war_widows_pension each get £10 a week, and an unused disregard does not +# pass to another payment or to the other member of a couple. The claimant's +# and the partner's payments count (SPCA s.5; reg 14: "any reference in this +# Part to the claimant shall apply equally to any partner of the claimant"). +# Reg 9 does not exclude these payments from Savings Credit qualifying income. # # Statutory 2026-27 rates (SI 2026/148 art 29): standard minimum guarantee # £238.00 single and £363.25 couple (reg 6(1)); severe disability addition @@ -112,7 +112,7 @@ pension_credit_income: (30 - 10) * 52 guarantee_credit: (238 - 20) * 52 -- name: Case 5, one person with an AFCS payment and a war widow's pension gets one £10. +- name: Case 5, one person with an AFCS payment and a war widow's pension gets £10 off each. period: 2026 absolute_error_margin: 0.01 input: @@ -130,10 +130,10 @@ members: [person1] country: ENGLAND output: - # (20 + 15 - 10) * 52 = 25 * 52 = 1,300; 12,376 - 1,300 = 11,076. - pension_credit_war_pension_income: (20 + 15 - 10) * 52 - pension_credit_income: (20 + 15 - 10) * 52 - guarantee_credit: (238 - 25) * 52 + # ((20 - 10) + (15 - 10)) * 52 = 15 * 52 = 780; 12,376 - 780 = 11,596. + pension_credit_war_pension_income: (10 + 5) * 52 + pension_credit_income: (10 + 5) * 52 + guarantee_credit: (238 - 15) * 52 - name: Case 6, each member of a couple gets their own £10. period: 2026 @@ -249,6 +249,7 @@ output: is_claimant_or_partner: [true, true, false, false] # Only the claimant's and partner's payments count (SPCA s.5; reg 14). + # Unchanged behaviour: passes on the base too. pension_credit_war_pension_income: 0 pension_credit_income: 0 @@ -330,3 +331,27 @@ # ITEPA 2003 s.639: no income tax on war widow's pensions. social_security_income: 0 income_tax: 0 + +- name: Case 13, an unused disregard on one payment does not pass to the person's other payment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 5 * 52 + war_widows_pension: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(5 - 10, 0) + max(30 - 10, 0) = 20 a week, not 5 + 30 - 10 = 25. + # 20 * 52 = 1,040; 12,376 - 1,040 = 11,336. + pension_credit_war_pension_income: 20 * 52 + pension_credit_income: 20 * 52 + guarantee_credit: (238 - 20) * 52 diff --git a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py index 91ca7a23ed..54b8f11291 100644 --- a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -5,7 +5,7 @@ 2002 reg 15(5)(aa) count "a guaranteed income payment" of the Armed Forces Compensation Scheme. Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of any of the following". `pension_credit_war_pension_income` counts the -claimant's and partner's `afcs` and `war_widows_pension`, each person's less +claimant's and partner's `afcs` and `war_widows_pension`, each payment less £10 a week, and Pension Credit income includes it. Invariants, for single people and couples over State Pension age renting from @@ -18,7 +18,7 @@ income disregard equals Pension Credit income less the counted war pension income. The difference is intended: the Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg - 29(1)(e) and (g) count these payments less £10 (Sch 5 para 1), but the + 29(1) count these payments and Sch 5 para 1 disregards £10 of them, but the Social Security Administration Act 1992 s.134(8) lets an authority modify its scheme to disregard "the whole or part" of a prescribed war disablement or war widow's pension, and `housing_benefit_applicable_income` @@ -28,13 +28,15 @@ pension contributions and capital of at most £10,000, so neither tariff income nor the earnings rules apply. 2. Bounds and closed form. The counted amount is between 0 and the gross - payments of the claimant and partner, and equals the sum over the claimant - and partner of max(0, AFCS + war widow's pension - £520 a year). + payments of the claimant and partner, and equals the sum over the + claimant's and partner's payments of max(0, payment - £520 a year). 3. Monotone. Guarantee Credit does not rise as one person's AFCS payment - rises, and does not change while the payment is at or below £10 a week. -4. Metamorphic. Moving an amount between `afcs` and `war_widows_pension` - within one person changes neither Pension Credit income nor Guarantee - Credit: one £10 a week applies to each person's payments together. + rises, and does not change while that payment is at or below £10 a week, + whatever war widow's pension the person also receives. +4. Metamorphic. Pension Credit income and Guarantee Credit depend on the + claimant's and partner's payments only as a collection: reassigning the + amounts between the two sources and between the claimant and partner + changes neither, because each payment has its own £10 a week. """ import numpy as np @@ -45,7 +47,7 @@ YEAR = 2026 WEEKS = 52 -# Sch IV para 1: £10 a week. +# Sch IV para 1: £10 a week of each payment. DISREGARD = 10 * WEEKS PENSIONS = np.arange(0, 20_001, 1_000) # Weekly AFCS payments for invariant 3, through the disregard and beyond. @@ -84,9 +86,6 @@ def adult(draw): state_pension=draw(money(15_000)), afcs=draw(weekly_payment()), war_widows_pension=draw(weekly_payment()), - # Share of the person's payments moved to the other source in - # invariant 4. - share=draw(st.floats(0, 1)), ) @@ -103,6 +102,8 @@ def family(draw): council_tax=draw(money(3_000)), savings=draw(st.one_of(st.just(0.0), money(10_000))), private_pension=draw(money(15_000)), + # Invariant 4 reassigns the payments in this order. + order=draw(st.permutations(range(2 * len(adults)))), ) @@ -168,6 +169,13 @@ def drawn_payments(fam): return [(a["afcs"], a["war_widows_pension"]) for a in fam["adults"]] +def reassigned_payments(fam): + """The family's payments, reassigned among sources and adults by `order`.""" + amounts = [amount for pair in drawn_payments(fam) for amount in pair] + moved = [amounts[k] for k in fam["order"]] + return [(moved[2 * j], moved[2 * j + 1]) for j in range(len(fam["adults"]))] + + def pension_grid(families, payments=drawn_payments): """Each family once per private pension on the PENSIONS grid.""" rows = [(fam, pension, payments(fam)) for fam in families for pension in PENSIONS] @@ -209,8 +217,9 @@ def test_counted_war_pension_income_bounds_and_closed_form(families): gross = get("afcs") + get("war_widows_pension") for i, fam in enumerate(families): expected = sum( - max(0.0, a["afcs"] + a["war_widows_pension"] - DISREGARD) - for a in fam["adults"] + max(0.0, payment - DISREGARD) + for pair in drawn_payments(fam) + for payment in pair ) assert np.all(war[i] >= 0), fam assert np.all(war[i] <= gross[i] + 0.01), fam @@ -220,13 +229,14 @@ def test_counted_war_pension_income_bounds_and_closed_form(families): @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=4)) def test_guarantee_credit_does_not_rise_with_an_afcs_payment(families): - # The first adult's war widow's pension is set to zero so that the grid - # is the whole of their payments; the partner keeps their drawn payments. + # The first adult's AFCS payment runs over the grid; their war widow's + # pension and the partner's payments stay as drawn. rows = [ ( fam, fam["private_pension"], - [(weekly * WEEKS, 0.0)] + drawn_payments(fam)[1:], + [(weekly * WEEKS, fam["adults"][0]["war_widows_pension"])] + + drawn_payments(fam)[1:], ) for fam in families for weekly in AFCS_GRID @@ -241,22 +251,11 @@ def test_guarantee_credit_does_not_rise_with_an_afcs_payment(families): ) -def moved(fam): - """Each person's total payments, split between the sources by `share`.""" - return [ - ( - a["share"] * (a["afcs"] + a["war_widows_pension"]), - (1 - a["share"]) * (a["afcs"] + a["war_widows_pension"]), - ) - for a in fam["adults"] - ] - - @PROPERTY_SETTINGS @given(st.lists(family(), min_size=1, max_size=4)) -def test_moving_payments_between_sources_within_a_person(families): +def test_reassigning_payments_between_sources_and_partners(families): get = pension_grid(families) - get_moved = pension_grid(families, payments=moved) + get_moved = pension_grid(families, payments=reassigned_payments) for variable in ["pension_credit_income", "guarantee_credit"]: original, after = get(variable), get_moved(variable) for i, fam in enumerate(families): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py index 92e6bb890d..7a3cc2d79c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py @@ -9,14 +9,16 @@ class pension_credit_war_pension_income(Variable): unit = GBP documentation = ( "The claimant's and partner's war disablement pensions, war widow's or " - "widower's pensions and Armed Forces Compensation Scheme payments " - "(afcs, which holds guaranteed income payments and war disablement " - "pensions), less the Sch. IV para. 1 disregard. One weekly disregard " - "applies to each person's payments together, and the claimant and " - "partner each have their own (reg. 14). Dependants' and other " - "benefit-unit members' payments do not count. Components that Sch. IV " - "paras 2 to 6 and 12 disregard in full, and reg. 15(5)(a) and (ab) " - "payments that count in full, are not separated in the data." + "widower's pensions and Armed Forces Compensation Scheme payments, each " + "payment less the Sch. IV para. 1 disregard. afcs holds Armed Forces " + "Compensation Scheme payments, including guaranteed income payments, " + "and war disablement pensions; war_widows_pension holds war widow's or " + "widower's pensions. Each of the two gets its own weekly disregard, " + "which does not pass to the other payment or to the other member of a " + "couple. Dependants' and other benefit-unit members' payments do not " + "count (State Pension Credit Act 2002 s.5; reg. 14). Components that " + "Sch. IV paras 2 to 6 and 12 disregard in full, and reg. 15(5)(a) and " + "(ab) payments that count in full, are not separated in the data." ) reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/15", @@ -28,7 +30,8 @@ class pension_credit_war_pension_income(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.pension_credit.income person = benunit.members - payments = add(person, period, ["afcs", "war_widows_pension"]) disregard = p.war_pension_disregard * WEEKS_IN_YEAR - counted = max_(payments - disregard, 0) + counted = 0 + for payment in ["afcs", "war_widows_pension"]: + counted += max_(person(payment, period) - disregard, 0) return benunit.sum(counted * person("is_claimant_or_partner", period)) From e6b2e12d8cbd8c65887fc38c4345395b4040280e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 15:59:12 -0400 Subject: [PATCH 124/224] Label the CTR applicant change in the legacy award readers property Council Tax Reduction reads the applicant and partner (#2078), and the applicant is the liable household head. These generated families flag no head, so an added member older than the claimant becomes the head, applies alone, and changes the applicant: intended. The two CTR readers are left out of the with/without comparison for those families only, which must have the added member as the resolved household head; the silent/as-drawn comparison still checks that the member's own reports never count. Co-Authored-By: Claude Opus 5.5 --- .../test_legacy_award_readers_properties.py | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 62efc0e89e..ef44e02ecc 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -36,8 +36,12 @@ member reports never changes any of these readers for the claimant's family or for its existing members; - adding such a member changes a reader only by ending the family's severe - disability premium, which a non-dependant aged 18 or over does (IS Regs - 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). That + disability premium, or, for the two Council Tax Reduction readers, by + becoming the household head (these families flag none, so the eldest + member is the head) who is liable and applies alone, so that the applicant + changes (CTR_APPLICANT_READERS). A non-dependant aged 18 or over ends the + premium (IS Regs 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para + 14(2)(a)(ii)). That rule is subject to para 13(3), which ignores a non-dependant on a qualifying disability benefit, a blind one and some carers; the added members here are none of those. The family's Income Support then falls by @@ -244,6 +248,40 @@ def other_members(draw): } +# Council Tax Reduction reads the applicant and partner, and the applicant is +# the person liable for the council tax, whom the model takes to be the +# household head (is_resolved_household_head; PolicyEngine/policyengine-uk#2009 +# and #2078). These families flag no head, so the head is the eldest member: an +# added member older than the claimant and partner becomes the liable head, +# who then applies alone (council_tax_reduction_head_applies_alone). Their +# presence then changes the applicant, which is intended, so those families +# are left out of the comparison with the family on its own for these two +# readers. What the added member reports still never changes them: the first +# property compares a silent added member with the member as drawn for every +# family. +CTR_APPLICANT_READERS = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_relevant_income_based_benefit", +] + + +def became_ctr_applicant(sim, k, starts, sizes): + """For each of the first k units, whether the member added in unit k + i + made it a family whose household head applies alone, and checks that this + happens only where that member is the resolved household head.""" + head_alone = sim.calculate("council_tax_reduction_head_applies_alone", YEAR) + head = sim.calculate("is_resolved_household_head", YEAR) + changed = [] + for i in range(k): + became = bool(head_alone[k + i]) and not bool(head_alone[i]) + if became: + added = starts[k + i] + sizes[k + i] - 1 + assert head[added], i + event("added member became the Council Tax Reduction applicant") + changed.append(became) + return changed + + def situation(units, year=YEAR, benunit_inputs=None): people, benunits, households = {}, {}, {} for i, (adults, non_couple, household, other) in enumerate(units): @@ -387,6 +425,7 @@ def test_other_members_awards_never_change_the_readers(drawn): fall = income_support[i] - income_support[k + i] lost = premium[i] - premium[k + i] assert -0.01 <= fall <= lost + 0.01, drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): @@ -395,6 +434,8 @@ def test_other_members_awards_never_change_the_readers(drawn): variable, drawn[i], ) + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue # Their presence changes one only by ending the premium, and # never the awards the family's own members report. if not premium_ended[i] or variable in PREMIUM_FREE_READERS: @@ -589,9 +630,12 @@ def _family_readers_never_change(drawn, enter_is_mixed_age_couple=False): k = len(drawn) for i in range(k): assert not flags[starts[k + i] + sizes[k + i] - 1], drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue assert np.isclose(values[i], values[k + i], atol=0.01), ( variable, drawn[i], From 7b225563e58d6aca76f8bee0680eb5fb9a3ff709 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 16:27:28 -0400 Subject: [PATCH 125/224] Floor Savings Credit excluded sources at nil; test working-age HB without a UC claim savings_credit_income now floors each person-level excluded source at nil, as pension_credit_income counts them since #2259, so the two never disagree on a negative input. Case 17 tested working-age Housing Benefit income, but on current main the couple's Universal Credit entitlement passports Housing Benefit (#2133) and makes its income nil. Set claims_universal_credit to false so the case tests the means test itself. Co-Authored-By: Claude Opus 5.5 --- .../pension_credit_prescribed_benefits_income.yaml | 4 ++++ .../pension_credit/savings_credit/savings_credit_income.py | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml index 5833bd54c2..26babd2558 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml @@ -467,6 +467,10 @@ members: [person1, person2] # As in Case 16 (the working-age disregard is HB Regs 2006 Sch 4). housing_benefit_applicable_income_disregard: 0 + # A legacy Housing Benefit claim: a Universal Credit claim would + # passport Housing Benefit (housing_benefit_on_passporting_benefit) + # and make its income nil, and this case tests the means test itself. + claims_universal_credit: false households: household: members: [person1, person2] diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py index 9cc98ec6fa..418d15124f 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py @@ -23,11 +23,12 @@ def formula(benunit, period, parameters): is_claimant_or_partner = person("is_claimant_or_partner", period) excluded_income = 0 for source in pc.savings_credit.excluded_income: - # Person-level sources count for the claimant and partner only; + # Person-level sources count for the claimant and partner only, + # each floored at nil as pension_credit_income counts them; # benefit-unit sources (working tax credit) count once. if person.entity.get_variable(source).entity.is_person: excluded_income += benunit.sum( - person(source, period) * is_claimant_or_partner + max_(0, person(source, period)) * is_claimant_or_partner ) else: excluded_income += add(benunit, period, [source]) From 22ddb9e61200b214eb7a65f2e20bf8560c7a1b71 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 16:27:50 -0400 Subject: [PATCH 126/224] Drop the war_widows_pension claim from the changelog; #2382 added it Co-Authored-By: Claude Opus 5.5 --- changelog.d/pension-credit-war-pensions.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/pension-credit-war-pensions.fixed.md b/changelog.d/pension-credit-war-pensions.fixed.md index 32de2fb3bd..abcdc35a3d 100644 --- a/changelog.d/pension-credit-war-pensions.fixed.md +++ b/changelog.d/pension-credit-war-pensions.fixed.md @@ -1 +1 @@ -- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Add a `war_widows_pension` input, counted in the benefit totals alongside `bsp`. +- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). From 685963c2d1384a26649808d70ce87f7264ecc1c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:39:01 +0000 Subject: [PATCH 127/224] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/1796.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/1796.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a222e8885a..ee089584e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.139.3] - 2026-10-19 + +### Fixed + +- - Updated Welsh residential Land Transaction Tax tables for annual model years from 2023 (main rates) and 2025 (higher rates), retaining their statutory change dates. + + ## [2.139.2] - 2026-10-19 ### Fixed diff --git a/changelog.d/1796.fixed.md b/changelog.d/1796.fixed.md deleted file mode 100644 index 8d18512306..0000000000 --- a/changelog.d/1796.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated Welsh residential Land Transaction Tax tables for annual model years from 2023 (main rates) and 2025 (higher rates), retaining their statutory change dates. diff --git a/pyproject.toml b/pyproject.toml index 78c8b1b5a5..f6b98c559a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.139.2" +version = "2.139.3" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 414e91532a3a73d80d014a8f04cb9672c0ed74e1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:55:16 -0400 Subject: [PATCH 128/224] Fit the prescribed-benefit properties to savings-credit-only HB and the carers overlap Two behaviours that reached main after these properties were written made them fail with every prescribed benefit at nil: - With a savings-credit-only award, Housing Benefit takes the Secretary of State's assessment plus the savings credit (HB (SPC) Regs 2006 reg 27), so the differential now compares only where neither credit is due. - SDA, IB and MA (and contribution-based JSA and contributory ESA) reduce Carer's Allowance and Carer Support Payment under the overlapping benefits rules (carers_allowance_overlapping_benefits). The pound-for-pound and reg 9 properties now add back the carer's benefit the payment displaces. Measured on the derandomized draws: the differential compares 849 of 1,071 points, 770 of them with a prescribed benefit and 220 with a carer. All four properties pass in 44 s. Co-Authored-By: Claude Opus 5.5 --- ...n_credit_prescribed_benefits_properties.py | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) 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 ad6f252a14..defdbfb181 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -24,9 +24,11 @@ Invariants: 1. Differential: Pension Credit and pension-age Housing Benefit assess the - same income. Where Guarantee Credit is not paid and Housing Benefit income - is above its zero floor, Housing Benefit applicable income plus its income - disregard equals Pension Credit income. The disregard is added back because + same income. Where neither Guarantee Credit nor Savings Credit is due and + Housing Benefit income is above its zero floor, Housing Benefit applicable + income plus its income disregard equals Pension Credit income. (With a + savings-credit-only award, Housing Benefit takes the Secretary of State's + assessment plus the savings credit, HB (SPC) Regs 2006 reg 27, by design.) The disregard is added back because the model applies the HB earnings disregard (HB (SPC) Regs Sch 4) whether or not there are earnings; there are none here. There is no other intended difference on this domain: both measures deduct the claimant's and @@ -36,11 +38,14 @@ 2. Guarantee Credit is non-increasing in each of the four benefits, and while it is positive it falls pound for pound with the extra Pension Credit income, which is the extra benefit less the extra income tax and National - Insurance (the model taxes IB). + Insurance (the model taxes IB), less any Carer's Allowance or Carer Support + Payment the benefit displaces: SDA, IB and MA overlap with them (Social + Security (Overlapping Benefits) Regulations 1979; carers_allowance_overlapping_benefits). 3. Savings Credit income never exceeds Pension Credit income. Adding an amount of a reg 9 source (working tax credit, IB, SDA, MA, contribution-based JSA or contributory ESA) changes Savings Credit income only through income tax - and National Insurance, and never raises Savings Credit. Draws for this + and National Insurance and through any Carer's Allowance or Carer Support + Payment it displaces (as in invariant 2), and never raises Savings Credit. Draws for this invariant are aged 77 or over: the model dates State Pension age as birth year plus the current State Pension age (66 in 2026), so they meet its test of reaching it before the 2016 Savings Credit cutoff. @@ -192,6 +197,7 @@ def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( families, [ "guarantee_credit", + "savings_credit", "pension_credit_income", "housing_benefit_eligible", "housing_benefit_applicable_income", @@ -199,10 +205,11 @@ def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( ], ) hb_income = g["housing_benefit_applicable_income"] - # Where Guarantee Credit is not paid, and away from the zero floor on - # Housing Benefit income. + # Where neither credit is due, and away from the zero floor on Housing + # Benefit income. compared = ( (g["guarantee_credit"] <= 0) + & (g["savings_credit"] <= 0) & g["housing_benefit_eligible"].astype(bool) & (hb_income > 0) ) @@ -235,20 +242,25 @@ def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( "pension_credit_income", "income_tax", "national_insurance", + "carers_allowance", + "carer_support_payment", ], ) gc = g["guarantee_credit"] pc_income = g["pension_credit_income"] tax = g["income_tax"] + g["national_insurance"] + carer_benefit = g["carers_allowance"] + g["carer_support_payment"] assert np.all(np.diff(gc, axis=-1) <= 0.01), (families, gc) - # The extra Pension Credit income is the extra benefit less the extra tax. + # The extra Pension Credit income is the extra benefit less the extra tax + # and less any carer's benefit the benefit displaces. extra_income = np.diff(pc_income, axis=-1) positive_income = (pc_income[..., :-1] > 0) & (pc_income[..., 1:] > 0) + expected = ( + np.diff(AMOUNTS) - np.diff(tax, axis=-1) + np.diff(carer_benefit, axis=-1) + ) assert np.allclose( - extra_income[positive_income], - (np.diff(AMOUNTS) - np.diff(tax, axis=-1))[positive_income], - atol=0.01, - ), (families, pc_income, tax) + extra_income[positive_income], expected[positive_income], atol=0.01 + ), (families, pc_income, tax, carer_benefit) # While Guarantee Credit is paid, it falls by exactly that much. paid = gc[..., 1:] > 0 assert np.allclose(-np.diff(gc, axis=-1)[paid], extra_income[paid], atol=0.01), ( @@ -287,6 +299,8 @@ def test_reg_9_sources_change_savings_credit_income_only_through_tax( "savings_credit", "income_tax", "national_insurance", + "carers_allowance", + "carer_support_payment", ], ) sc_income = g["savings_credit_income"] @@ -299,9 +313,13 @@ def test_reg_9_sources_change_savings_credit_income_only_through_tax( ) tax = g["income_tax"] + g["national_insurance"] extra_tax = tax[..., 1] - tax[..., 0] + carer_benefit = g["carers_allowance"] + g["carer_support_payment"] + carer_change = carer_benefit[..., 1] - carer_benefit[..., 0] assert np.allclose( - sc_income[..., 1], np.maximum(sc_income[..., 0] - extra_tax, 0), atol=0.01 - ), (families, amount, sc_income, tax) + sc_income[..., 1], + np.maximum(sc_income[..., 0] - extra_tax + carer_change, 0), + atol=0.01, + ), (families, amount, sc_income, tax, carer_benefit) @PROPERTY_SETTINGS From 0a6b36b81025b830afa8cb342bc4df8942fd4c9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:56:21 -0400 Subject: [PATCH 129/224] Give CTR and Income Support their own pension contribution shares Council Tax Reduction and Income Support deducted a hard-coded 0.5 of pension contributions, while the CTR pensioner earnings disregards were capped at Housing Benefit net earnings, which read Housing Benefit's share. A reform of HB's share therefore moved the CTR disregard cap but not CTR income, and neither scheme could be reformed on its own. Each scheme's regulations state their own one-half and none refers to HB's, so each gets its own parameter at 0.5: - Income Support: SI 1987/1967 regs 36(3)(b) and 38. - CTR, by scheme: England pensioners (SI 2012/2885 Sch 1 paras 19, 29), the England default scheme (SI 2012/2886), Wales (WSI 2013/3029), Scotland (SSI 2012/319, SSI 2021/249) and the working-age schemes of Merton, Kingston upon Thames, Newham, Westminster and Oxford. council_tax_reduction_pension_contribution_deduction_rate picks the family's scheme's share. CTR applicable income reads it, and the pensioner disregards now cap at council_tax_reduction_net_earnings, which shares HB's earnings rules (factored into _net_earnings.py) at the scheme's own share. Current-law outputs are unchanged. Fixes #2515 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2515.fixed.md | 1 + .../pension_contribution_deduction_rate.yaml | 13 + .../pension_contribution_deduction_rate.yaml | 17 + .../pension_contribution_deduction_rate.yaml | 13 + .../pension_contribution_deduction_rate.yaml | 11 + .../pension_contribution_deduction_rate.yaml | 11 + .../pension_contribution_deduction_rate.yaml | 11 + .../pension_contribution_deduction_rate.yaml | 13 + .../pension_contribution_deduction_rate.yaml | 19 + .../pension_contribution_deduction_rate.yaml | 21 + .../pension_contribution_deduction_rate.yaml | 15 + .../pension_contribution_deduction_rate.yaml | 151 ++++ .../pension_contribution_deduction_rate.yaml | 783 ++++++++++++++++++ ..._contribution_deduction_rate_properties.py | 243 ++++++ .../applicable_income/_net_earnings.py | 49 ++ .../housing_benefit_net_earnings.py | 39 +- .../dwp/income_support_applicable_income.py | 10 +- ...council_tax_reduction_applicable_income.py | 10 +- .../council_tax_reduction_net_earnings.py | 40 + ...ion_pension_contribution_deduction_rate.py | 87 ++ ..._reduction_pensioner_earnings_disregard.py | 10 +- 21 files changed, 1524 insertions(+), 43 deletions(-) create mode 100644 changelog.d/2515.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py diff --git a/changelog.d/2515.fixed.md b/changelog.d/2515.fixed.md new file mode 100644 index 0000000000..1154378a6b --- /dev/null +++ b/changelog.d/2515.fixed.md @@ -0,0 +1 @@ +- Council Tax Reduction and Income Support read their own pension contribution deduction shares (one-half in each scheme) instead of a hard-coded 0.5, and the Council Tax Reduction pensioner earnings disregards cap at net earnings with the scheme's own share, so a reform of Housing Benefit's share no longer moves them. diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..09b055db92 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,13 @@ +description: Income Support deducts this share of pension contributions when it calculates a claimant's net earnings and a self-employed earner's net profit. +values: + 1988-04-11: 0.5 +metadata: + period: year + unit: /1 + label: Income Support net earnings pension contribution deduction rate + economy: false + reference: + - title: The Income Support (General) Regulations 1987, regulation 36(3)(b) (employed earners) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/36 + - title: The Income Support (General) Regulations 1987, regulation 38(1)(b)(ii), (3)(c) and (9)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/38 diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0e9ece3f08 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,17 @@ +description: The England Council Tax Reduction default scheme deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit, for pensioners and for applicants who are not pensioners and have no award of Universal Credit. The default scheme applies where a billing authority has made no scheme of its own. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: England Council Tax Reduction default scheme pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 42(2)(b) and (c) and (5)(c) (employed earners who are pensioners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/42 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 52(3)(b) and (c) and (6)(c) (employed earners who are not pensioners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 61(1)(b)(ii) and (c)(ii), (3)(c) and (9)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/61 + - title: Local Government Finance Act 1992 Sch. 1A para. 4 (the default scheme applies where a billing authority has made no scheme) + href: https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/4 diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0b4b2c2b9b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,13 @@ +description: The England Council Tax Reduction scheme for pensioners deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: England Council Tax Reduction pensioner pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 19(2)(b) and (c) and (5)(c) (employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 29(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29 diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..213fa1d994 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Kingston upon Thames Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Kingston upon Thames Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Council Tax Reduction Scheme 2026 - 2027, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..e3dd4bc680 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Merton Council Tax Support scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Merton Council Tax Support pension contribution deduction rate + economy: false + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..3a898a844b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Newham Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Newham Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Council Tax Reduction Scheme 2026, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026 diff --git a/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..2856e8dc9c --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,13 @@ +description: The Oxford Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Oxford Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Part 1 para. 18 (50% of contributions to occupational or personal pension schemes) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27 + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Annex 5 (one half of personal pension scheme payments, self-employed earners) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27/3 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0345f58a56 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,19 @@ +description: The Scotland Council Tax Reduction schemes deduct this share of pension contributions when they calculate an applicant's net earnings and a self-employed earner's net profit, for applicants of pension age and for applicants under pension age with no award of Universal Credit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Scotland Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 33(2)(b) and (c) and (4)(c) (employed earners of pension age) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/33 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 36(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners of pension age) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/36 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 50(3)(b) and (c) and (5)(c) (employed earners under pension age with no award of Universal Credit, from 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/50 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 51(9) (self-employed earners under pension age with no award of Universal Credit, by SSI 2012/303 reg. 37) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/51 + - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 35(3)(b) and (c) and (6)(c) and reg. 37(1)(b)(ii), (3)(c) and (9)(b) (persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0c847d4b13 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,21 @@ +description: The Wales Council Tax Reduction scheme deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit, for pensioners and for applicants who are not pensioners and have no award of Universal Credit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Wales Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 1 para. 13(2)(b) and (c) and (5)(c) (employed earners who are pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 1 para. 23(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners who are pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/23 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 15(3)(b) and (c) and (6)(c) (employed earners who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 24(1)(b)(ii) and (c)(ii), (3)(c) and (9)(b) (self-employed earners who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/24 + - title: The Council Tax Reduction Schemes (Default Scheme) (Wales) Regulations 2013 Sch. paras. 39, 49 and 58 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 1 paras. 13 and 23 and Sch. 6 paras. 15 and 24 (2013-14 schemes; revoked from 1 April 2014) + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..93b9b5309f --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,15 @@ +description: The Westminster Council Tax Support scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. The council's published scheme says the default scheme forms the basis of its working-age scheme and does not restate this rule, so the default scheme's one-half is used. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Westminster Council Tax Support pension contribution deduction rate + economy: false + reference: + - title: Council Tax Support scheme for 2026/27, section 4 (the Default Scheme Regulations 2012 form the basis of the working-age scheme) + href: https://www.westminster.gov.uk/sites/default/files/media/documents/council-tax-support-scheme-for-2026-27.pdf + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 52(3)(b) and (c) and (6)(c) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 61 + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/61 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..bf6c22ef1a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml @@ -0,0 +1,151 @@ +# Income Support deducts one-half of pension contributions from net earnings +# (IS Regs 1987 reg 36(3)(b)) and net profit (reg 38(1)(b)(ii), (3)(c) and +# (9)(b)), with no reference to Housing Benefit's share. The rates below 0.5 +# are model reforms, not current law. +# +# Each person earns 10,000 a year, below the personal allowance and the primary +# threshold, so no income tax or National Insurance is due. The single +# claimant's earnings disregard is 5 a week (260 a year) and a couple's 10 a +# week (520 a year). + +- name: At current law Income Support deducts half of pension contributions + # 10,000 - 0.5 x 2,000 - 260 = 8,740. The same claimant without the + # contribution has 9,740 (means_test_income_members.yaml). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_contributions: 2_000 + income_tax: 0 + national_insurance: 0 + income_support_applicable_income: 8_740 + +- name: A reform of the Income Support share moves Income Support applicable income + # Model reform, not current law: 10,000 - 0.25 x 2,000 - 260 = 9,240, which is + # 500 more than at half: 2,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.income_support.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_support_applicable_income: 9_240 + # Housing Benefit keeps its own half: 10,000 - 0.5 x 2,000. + housing_benefit_net_earnings: 9_000 + +- name: A reform of the Housing Benefit share leaves Income Support applicable income + # Model reform, not current law. Housing Benefit net earnings become + # 10,000 - 0.25 x 2,000 = 9,500; Income Support keeps 8,740. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + housing_benefit_net_earnings: 9_500 + income_support_applicable_income: 8_740 + +- name: A reform of a Council Tax Reduction share leaves Income Support applicable income + # Model reform, not current law. The claimant lives in England outside the + # councils whose schemes are modelled, so Council Tax Reduction reads the + # England default scheme's share: 10,000 - 0.25 x 2,000 = 9,500. Income + # Support keeps 8,740. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.default_scheme.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + output: + council_tax_reduction_applicable_income: 9_500 + income_support_applicable_income: 8_740 + +- name: A couple's contributions are summed at the Income Support share + # 20,000 - 0.5 x (2,000 + 1,000) - 520 = 17,980. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + income_support_applicable_income: 17_980 + +- name: A couple's Income Support applicable income falls by contributions times the change in share + # Model reform, not current law: 20,000 - 0.25 x 3,000 - 520 = 18,730, which + # is 750 more than at half: 3,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.income_support.means_test.pension_contribution_deduction_rate: 0.25 + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + income_support_applicable_income: 18_730 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..9f34719d19 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml @@ -0,0 +1,783 @@ +# Each Council Tax Reduction scheme deducts its own share of pension +# contributions from earnings, one-half in every scheme modelled: +# - England pensioners: SI 2012/2885 Sch 1 paras 19(2)(b)-(c) and 29; +# - England default scheme: SI 2012/2886 Sch paras 42, 52 and 61; +# - Wales: WSI 2013/3029 Sch 1 paras 13 and 23 and Sch 6 paras 15 and 24; +# - Scotland: SSI 2012/319 regs 33 and 36 and SSI 2021/249 regs 50 and 51(9); +# - Merton, Kingston upon Thames and Newham: their schemes' paras 52 and 61; +# - Oxford: its scheme's para 18 and Annex 5; +# - Westminster: the default scheme, which its scheme says it is based on. +# None refers to Housing Benefit's share, so a reform of one scheme's share +# moves that scheme's families only, and a reform of Housing Benefit's moves +# none of them. The rates below 0.5 are model reforms, not current law. +# +# Every person earns 10,000 a year, below the personal allowance and the +# primary threshold, so no income tax or National Insurance is due, and pays +# 2,000 into a personal pension. Universal Credit and Pension Credit are not +# claimed. Applicable income is 10,000 - rate x 2,000, less the single +# pensioner's 5 a week (260 a year) earnings disregard (SI 2012/2885 Sch 4 +# para 8(a)) for the 70-year-old. Net earnings are 10,000 - rate x 2,000. + +- name: At current law every scheme deducts half of pension contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + wales_household: + members: [wales] + country: WALES + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + england_default_household: + members: [england_default] + country: ENGLAND + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + output: + # In order: Scotland and Wales at working age, the England pensioner, + # England working age outside the modelled councils, and working age in + # Merton, Kingston upon Thames, Newham, Westminster and Oxford. + pension_contributions: [2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000] + income_tax: [0, 0, 0, 0, 0, 0, 0, 0, 0] + national_insurance: [0, 0, 0, 0, 0, 0, 0, 0, 0] + council_tax_reduction_pensioner: [false, false, true, false, false, false, false, false, false] + council_tax_reduction_pension_contribution_deduction_rate: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + # 10,000 - 0.5 x 2,000, less 260 for the pensioner. + council_tax_reduction_applicable_income: [9000, 9000, 8740, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + housing_benefit_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0, 260, 0, 0, 0, 0, 0, 0] + +- name: A reform of the Scotland share moves only that scheme's applicable income + # Model reform, not current law. + # Scotland, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, England working age outside the modelled councils: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.scotland.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + england_default_household: + members: [england_default] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Wales share moves only that scheme's applicable income + # Model reform, not current law. + # Wales, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, England working age outside the modelled councils: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.wales.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + households: + wales_household: + members: [wales] + country: WALES + england_default_household: + members: [england_default] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the England pensioner share moves only that scheme's applicable income + # Model reform, not current law. + # England pensioner scheme: 10,000 - 0.25 x 2,000 - 260 = 9,240. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.pensioners.means_test.pension_contribution_deduction_rate: 0.25 + people: + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9240, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [260, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the England default scheme share moves only that scheme's applicable income + # Model reform, not current law. + # England working age outside the modelled councils: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.default_scheme.means_test.pension_contribution_deduction_rate: 0.25 + people: + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + england_default_household: + members: [england_default] + country: ENGLAND + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Merton share moves only that scheme's applicable income + # Model reform, not current law. + # Merton, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.merton.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Kingston upon Thames share moves only that scheme's applicable income + # Model reform, not current law. + # Kingston upon Thames, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.kingston_upon_thames.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Newham share moves only that scheme's applicable income + # Model reform, not current law. + # Newham, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.newham.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Westminster share moves only that scheme's applicable income + # Model reform, not current law. + # Westminster, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.westminster.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Oxford share moves only that scheme's applicable income + # Model reform, not current law. + # Oxford, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.oxford.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Housing Benefit share moves no Council Tax Reduction scheme + # Model reform, not current law. Housing Benefit net earnings become + # 10,000 - 0.25 x 2,000 = 9,500; every scheme's figures stay as at current law. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + wales_household: + members: [wales] + country: WALES + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + england_default_household: + members: [england_default] + country: ENGLAND + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + output: + housing_benefit_net_earnings: [9500, 9500, 9500, 9500, 9500, 9500, 9500, 9500, 9500] + council_tax_reduction_pension_contribution_deduction_rate: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + council_tax_reduction_applicable_income: [9000, 9000, 8740, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0, 260, 0, 0, 0, 0, 0, 0] + +- name: A pensioner's net earnings below the disregard are all disregarded at current law + # Net earnings are 300 - 0.5 x 200 = 200, below the single pensioner's 260, so + # the disregard is 200 (SI 2012/2885 Sch 4 para 8(a), capped at net earnings). + # Applicable income is the State Pension and earnings, 5,300, less 0.5 x 200 + # and the 200 disregard: 5,000. Income tax is nil (5,300 is below the + # personal allowance). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + income_tax: 0 + council_tax_reduction_pension_contribution_deduction_rate: 0.5 + housing_benefit_net_earnings: 200 + council_tax_reduction_net_earnings: 200 + council_tax_reduction_pensioner_earnings_disregard: 200 + council_tax_reduction_applicable_income: 5_000 + +- name: A reform of the England pensioner share moves the disregard cap with the deduction + # Model reform, not current law. Net earnings for Council Tax Reduction are + # 300 - 0.25 x 200 = 250, still below 260, so 250 is disregarded. Applicable + # income is 5,300 - 0.25 x 200 - 250 = 5,000: earnings wholly disregarded + # deduct nothing, whatever the share. Housing Benefit net earnings stay 200. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.pensioners.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: 0.25 + housing_benefit_net_earnings: 200 + council_tax_reduction_net_earnings: 250 + council_tax_reduction_pensioner_earnings_disregard: 250 + council_tax_reduction_applicable_income: 5_000 + +- name: A reform of the Housing Benefit share leaves the Council Tax Reduction disregard cap + # Model reform, not current law. Housing Benefit net earnings become + # 300 - 0.25 x 200 = 250, but the Council Tax Reduction cap stays at its own + # 300 - 0.5 x 200 = 200, so the disregard stays 200 and applicable income + # 5,000. Reading Housing Benefit's net earnings, the disregard was 250 and + # applicable income 5,300 - 0.5 x 200 - 250 = 4,950. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: 0.5 + housing_benefit_net_earnings: 250 + council_tax_reduction_net_earnings: 200 + council_tax_reduction_pensioner_earnings_disregard: 200 + council_tax_reduction_applicable_income: 5_000 + +- name: A couple's contributions are summed at the scheme's share + # Scotland, working age. Each partner earns 10,000 and they contribute 2,000 + # and 1,000. Applicable income is 20,000 - 0.5 x 3,000 = 18,500. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [head, partner] + country: SCOTLAND + output: + council_tax_reduction_applicable_income: 18_500 + council_tax_reduction_net_earnings: 18_500 + +- name: A couple's applicable income falls by their contributions times the change in share + # Model reform, not current law. At a quarter, 20,000 - 0.25 x 3,000 = 19,250: + # 750 more than at half, which is 3,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.scotland.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [head, partner] + country: SCOTLAND + output: + council_tax_reduction_applicable_income: 19_250 + council_tax_reduction_net_earnings: 19_250 diff --git a/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py new file mode 100644 index 0000000000..45310d1403 --- /dev/null +++ b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py @@ -0,0 +1,243 @@ +"""Property-based tests for the legacy means tests' pension contribution shares. + +Each scheme's regulations state their own share of pension contributions to +deduct from earnings, one-half in every scheme modelled, and none refers to +another scheme's: + +- Housing Benefit: SI 2006/213 regs 36(3)(b)-(c) and 38; SI 2006/214 regs + 36(2)(b)-(c) and 39 (gov.dwp.housing_benefit.means_test); +- Income Support: SI 1987/1967 regs 36(3)(b) and 38 (gov.dwp.income_support); +- Council Tax Reduction, each scheme under gov.local_authorities: + the England pensioner scheme (SI 2012/2885 Sch 1 paras 19 and 29), Wales + (WSI 2013/3029 Sch 1 paras 13 and 23, Sch 6 paras 15 and 24), Scotland (SSI + 2012/319 regs 33 and 36, SSI 2021/249 regs 50 and 51(9)), the working-age + schemes of Merton, Kingston upon Thames, Newham, Westminster and Oxford, and + the England default scheme (SI 2012/2886 Sch paras 42, 52 and 61) for every + other family. + +Invariants, for any generated population of families whose floors are slack +(earnings of at least 15,000 per adult and contributions of at most a fifth of +them, so no net-earnings, disregard or applicable-income floor binds, and no +Pension Credit or Universal Credit claim): + +1. Exactness: when each scheme's share moves from 0.5 to r, the scheme's + applicable income falls by exactly the family's contributions x (r - 0.5), + and so do the scheme's net earnings. +2. Locality: every share is drawn distinct, so a family that read another + scheme's share (or Housing Benefit's) would miss invariant 1. Pensioners in + Merton follow the England pensioner scheme, not Merton's, and families in + Northern Ireland, which has no Council Tax Reduction, read the England + default scheme's share. +3. The pensioner earnings disregards do not move when the disregard caps are + slack. +4. Differential: at current law Council Tax Reduction net earnings equal + Housing Benefit net earnings, as both deduct half. +5. Current law: every share is 0.5 in every financial year from 2015 (the + first year the model simulates) to 2030, Income Support's and Housing + Benefit's too. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +CTR = "gov.local_authorities.{}.council_tax_reduction{}.means_test.pension_contribution_deduction_rate" +PARAMETERS = { + "england_pensioner": CTR.format("england", ".pensioners"), + "england_default": CTR.format("england", ".default_scheme"), + "scotland": CTR.format("scotland", ""), + "wales": CTR.format("wales", ""), + "merton": CTR.format("merton", ""), + "kingston_upon_thames": CTR.format("kingston_upon_thames", ""), + "newham": CTR.format("newham", ""), + "westminster": CTR.format("westminster", ""), + "oxford": CTR.format("oxford", ""), + "housing_benefit": ( + "gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate" + ), + "income_support": ( + "gov.dwp.income_support.means_test.pension_contribution_deduction_rate" + ), +} +# Each route: (country, local authority, pension age, the share it reads). +ROUTES = { + "england_pensioner": ("ENGLAND", "MAIDSTONE", True, "england_pensioner"), + "merton_pensioner": ("ENGLAND", "MERTON", True, "england_pensioner"), + "england_default": ("ENGLAND", "MAIDSTONE", False, "england_default"), + "scotland_pension_age": ("SCOTLAND", "GLASGOW_CITY", True, "scotland"), + "scotland_working_age": ("SCOTLAND", "GLASGOW_CITY", False, "scotland"), + "wales_pension_age": ("WALES", "CARDIFF", True, "wales"), + "wales_working_age": ("WALES", "CARDIFF", False, "wales"), + "merton": ("ENGLAND", "MERTON", False, "merton"), + "kingston_upon_thames": ( + "ENGLAND", + "KINGSTON_UPON_THAMES", + False, + "kingston_upon_thames", + ), + "newham": ("ENGLAND", "NEWHAM", False, "newham"), + "westminster": ("ENGLAND", "WESTMINSTER", False, "westminster"), + "oxford": ("ENGLAND", "OXFORD", False, "oxford"), + "northern_ireland": ("NORTHERN_IRELAND", "BELFAST", False, "england_default"), +} +# State Pension age is 66 to 67 over the years tested, so 68 and over is +# pension age and 55 and under working age. +WORKING_AGE = (25, 55) +PENSION_AGE = (68, 90) +# Shares other than 0.5, on a grid of twentieths so that each is exact in +# binary and every pair differs by at least 0.05. +SHARES = [i / 20 for i in range(21) if i != 10] +TOLERANCE = 0.05 + + +@st.composite +def families(draw): + route = draw(st.sampled_from(sorted(ROUTES))) + pension_age = ROUTES[route][2] + low, high = PENSION_AGE if pension_age else WORKING_AGE + head_age = draw(st.integers(low, high)) + adults = [] + for _ in range(draw(st.integers(1, 2))): + earnings = draw(st.floats(15_000, 60_000, allow_nan=False)) + share = draw(st.one_of(st.just(0.0), st.floats(0.01, 0.2))) + # Partners within five years of each other, so neither is presumed + # the other's child. + age = draw(st.integers(max(low, head_age - 5), min(high, head_age + 5))) + adults.append( + dict( + age=age, + employment_income=earnings, + personal_pension_contributions=round(earnings * share, 2), + ) + ) + return dict(route=route, adults=adults) + + +@st.composite +def reforms(draw): + shares = draw( + st.lists( + st.sampled_from(SHARES), + min_size=len(PARAMETERS), + max_size=len(PARAMETERS), + unique=True, + ) + ) + return dict(zip(PARAMETERS, shares)) + + +def situation(units, year): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + country, local_authority, _, _ = ROUTES[unit["route"]] + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"a{i}_{j}" + people[name] = {key: {year: value} for key, value in adult.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + "would_claim_pc": {year: False}, + "income_support": {year: 0.0}, + "jsa_income": {year: 0.0}, + "esa_income": {year: 0.0}, + } + households[f"h{i}"] = { + "members": names, + "country": {year: country}, + "local_authority": {year: local_authority}, + "savings": {year: 0.0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +VARIABLES = [ + "council_tax_reduction_pension_contribution_deduction_rate", + "council_tax_reduction_applicable_income", + "council_tax_reduction_net_earnings", + "council_tax_reduction_pensioner_earnings_disregard", + "council_tax_reduction_pensioner", + "income_support_applicable_income", + "housing_benefit_net_earnings", +] + + +def calculate(units, year, reform=None): + sim = Simulation(situation=situation(units, year), reform=reform) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in VARIABLES} + + +def contributions(unit): + return sum(adult["personal_pension_contributions"] for adult in unit["adults"]) + + +@PROPERTY_SETTINGS +@given( + st.lists(families(), min_size=1, max_size=26), + reforms(), + st.sampled_from([2026, 2028, 2030]), +) +def test_each_scheme_moves_by_contributions_times_its_own_change_in_share( + units, shares, year +): + base = calculate(units, year) + reform = {PARAMETERS[key]: {str(year): share} for key, share in shares.items()} + moved = calculate(units, year, reform) + for i, unit in enumerate(units): + country, _, pension_age, scheme = ROUTES[unit["route"]] + c = contributions(unit) + case = (unit, shares, year) + # The route is the one intended: pension-age families in Great + # Britain are the schemes' pensioners. + in_great_britain = country != "NORTHERN_IRELAND" + pensioner = base["council_tax_reduction_pensioner"][i] + assert bool(pensioner) == (pension_age and in_great_britain), case + # Floors are slack: income stays positive on both sides. + for values in (base, moved): + assert values["council_tax_reduction_applicable_income"][i] > 0, case + assert values["income_support_applicable_income"][i] > 0, case + rate = "council_tax_reduction_pension_contribution_deduction_rate" + assert base[rate][i] == 0.5, case + assert moved[rate][i] == shares[scheme], case + # Invariant 1 (with 2): exactly contributions x the scheme's change. + for variable, share in ( + ("council_tax_reduction_applicable_income", shares[scheme]), + ("council_tax_reduction_net_earnings", shares[scheme]), + ("income_support_applicable_income", shares["income_support"]), + ("housing_benefit_net_earnings", shares["housing_benefit"]), + ): + change = base[variable][i] - moved[variable][i] + assert abs(change - c * (share - 0.5)) < TOLERANCE, (variable, case) + # Invariant 3: slack caps leave the disregards where they were. + disregard = "council_tax_reduction_pensioner_earnings_disregard" + assert abs(base[disregard][i] - moved[disregard][i]) < TOLERANCE, case + # Invariant 4: equal shares, equal net earnings. + assert ( + abs( + base["council_tax_reduction_net_earnings"][i] + - base["housing_benefit_net_earnings"][i] + ) + < TOLERANCE + ), case + + +def test_every_share_is_one_half_in_every_year(): + for year in range(2015, 2031): + # Read in each financial year (30 April, the model's convention). + parameters = system.parameters(f"{year}-04-30") + for key, path in PARAMETERS.items(): + node = parameters + for name in path.split("."): + node = getattr(node, name) + assert node == 0.5, (key, year) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py new file mode 100644 index 0000000000..5ac9560f4d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py @@ -0,0 +1,49 @@ +from policyengine_uk.model_api import * + + +def person_net_earnings(person, period, pension_contribution_deduction_rate): + """A person's net earnings under the legacy means tests' earnings rules. + + Employment income plus self-employment profit (each floored at zero) plus + statutory sick and maternity pay, less the income tax attributed to them, + Class 1, 2 and 4 National Insurance and the scheme's share of pension + contributions, floored at zero. Housing Benefit + (housing_benefit_net_earnings) and the Council Tax Reduction pensioner + earnings disregards (council_tax_reduction_net_earnings) use these + deductions. Each scheme's regulations state their own share of pension + contributions, so each passes its own rate. + """ + employment_income = person("employment_income", period) + self_employment_income = person("self_employment_income", period) + # Statutory sick and maternity pay are earnings (HB Regs 2006 reg + # 35(1)(i); pension age reg 35(1)(h)). The model taxes them as employment income + # (employment_benefits) but leaves them out of total_income. + statutory_pay = person("employment_benefits", period) + earnings = ( + max_(employment_income, 0) + max_(self_employment_income, 0) + statutory_pay + ) + # Income tax is attributed by the earnings' share of total income, + # with statutory pay added and before any negative component (a + # loss), so that a loss set against other income does not drop or + # inflate the share of tax on these earnings. + income_before_losses = person("total_income", period) + statutory_pay + system = person.simulation.tax_benefit_system + for component in system.get_variable("total_income").adds: + income_before_losses += max_(-person(component, period), 0) + earnings_share = np.divide( + earnings, + income_before_losses, + out=np.zeros_like(earnings), + where=income_before_losses > 0, + ) + income_tax = person("income_tax", period) * min_(earnings_share, 1) + national_insurance = add( + person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + pension_contributions = ( + person("pension_contributions", period) * pension_contribution_deduction_rate + ) + return max_( + earnings - income_tax - national_insurance - pension_contributions, + 0, + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py index 8f8d97ac9a..c7b939c8f9 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.applicable_income._net_earnings import ( + person_net_earnings, +) class housing_benefit_net_earnings(Variable): @@ -46,40 +49,8 @@ class housing_benefit_net_earnings(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.means_test person = benunit.members - employment_income = person("employment_income", period) - self_employment_income = person("self_employment_income", period) - # Statutory sick and maternity pay are earnings (reg 35(1)(i); pension - # age reg 35(1)(h)). The model taxes them as employment income - # (employment_benefits) but leaves them out of total_income. - statutory_pay = person("employment_benefits", period) - earnings = ( - max_(employment_income, 0) + max_(self_employment_income, 0) + statutory_pay - ) - # Income tax is attributed by the earnings' share of total income, - # with statutory pay added and before any negative component (a - # loss), so that a loss set against other income does not drop or - # inflate the share of tax on these earnings. - income_before_losses = person("total_income", period) + statutory_pay - system = person.simulation.tax_benefit_system - for component in system.get_variable("total_income").adds: - income_before_losses += max_(-person(component, period), 0) - earnings_share = np.divide( - earnings, - income_before_losses, - out=np.zeros_like(earnings), - where=income_before_losses > 0, - ) - income_tax = person("income_tax", period) * min_(earnings_share, 1) - national_insurance = add( - person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - ) - pension_contributions = ( - person("pension_contributions", period) - * p.pension_contribution_deduction_rate - ) - net_earnings = max_( - earnings - income_tax - national_insurance - pension_contributions, - 0, + net_earnings = person_net_earnings( + person, period, p.pension_contribution_deduction_rate ) # The claimant and partner: a child's or young person's earnings are # not the claimant's (reg 25(3); pension age reg 23(3)). diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index eea502562e..b0bad392d8 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -7,7 +7,11 @@ class income_support_applicable_income(Variable): label = "Relevant income for Income Support means test" definition_period = YEAR unit = GBP - reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8" + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/36", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/38", + ) def formula(benunit, period, parameters): # Members whose income counts: the claimant and partner and, as the model did @@ -35,8 +39,10 @@ def formula(benunit, period, parameters): income += add_for_members(benunit, period, ["social_security_income"], members) income += benunit("income_support_tariff_income", period) income -= tax + # IS Regs 1987 reg 36(3)(b) and reg 38(1)(b)(ii), (3)(c) and (9)(b). income -= ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) + * IS.means_test.pension_contribution_deduction_rate ) # Schedule 8 paras 5, 6 and 10 use mutually exclusive claimant types. mt = IS.means_test 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 55575e1987..65bef905a7 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 @@ -112,9 +112,13 @@ def formula(benunit, period, parameters): benunit, period, ["basic_income"], members ) - pension_contributions = ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 - ) + # The scheme's own share of pension contributions (one-half in each: + # SI 2012/2885 Sch 1 para 19(2)(b)-(c), WSI 2013/3029 Sch 1 para 13 + # and Sch 6 para 15, SSI 2012/319 reg 33 and SSI 2021/249 reg 50(3), + # and the councils' schemes). + pension_contributions = add_for_members( + benunit, period, ["pension_contributions"], members + ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) tax = add_for_members( benunit, period, ["income_tax", "national_insurance"], members ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py new file mode 100644 index 0000000000..e76587f4bc --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.applicable_income._net_earnings import ( + person_net_earnings, +) + + +class council_tax_reduction_net_earnings(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction net earnings" + documentation = ( + "Net earnings of the claimant and partner, from which the pensioner " + "Council Tax Reduction earnings disregards are taken. The earnings and " + "deductions are those of housing_benefit_net_earnings, except the " + "share of pension contributions, which is the scheme's own " + "(council_tax_reduction_pension_contribution_deduction_rate): " + "SI 2012/2885 Sch 1 para 19(2)(b)-(c), WSI 2013/3029 Sch 1 para " + "13(2)(b)-(c) and SSI 2012/319 reg 33(2)(b)-(c) each deduct one-half " + "and do not refer to Housing Benefit's share." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + rate = benunit( + "council_tax_reduction_pension_contribution_deduction_rate", period + ) + net_earnings = person_net_earnings(person, period, benunit.project(rate)) + # The claimant and partner, as for Housing Benefit. + claimant_or_partner = person("is_claimant_or_partner", period) + return benunit.sum(net_earnings * claimant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py new file mode 100644 index 0000000000..6b9b5372a6 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py @@ -0,0 +1,87 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_england_pensioner_scheme, + is_kingston_upon_thames_working_age, + is_merton_working_age, + is_newham_working_age, + is_oxford_working_age, + is_scotland_scheme, + is_wales_scheme, + is_westminster_working_age, +) + + +class council_tax_reduction_pension_contribution_deduction_rate(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction pension contribution deduction rate" + documentation = ( + "The share of pension contributions deducted from earnings in the " + "Council Tax Reduction means test, under the family's own scheme. " + "Each scheme states its own share, one-half in every scheme modelled: " + "the England pensioner scheme (SI 2012/2885 Sch 1 paras 19 and 29), " + "Wales (WSI 2013/3029 Sch 1 paras 13 and 23 and Sch 6 paras 15 and " + "24), Scotland (SSI 2012/319 regs 33 and 36; SSI 2021/249 regs 50 and " + "51(9)) and the working-age schemes of Merton, Kingston upon Thames, " + "Newham, Westminster and Oxford. None of them refers to Housing " + "Benefit's share, so a reform of one scheme's share moves only that " + "scheme. English working-age families outside those councils, whose " + "reductions the model takes as reported, and families outside Great " + "Britain, where there is no Council Tax Reduction, read the England " + "default scheme's share (SI 2012/2886 Sch paras 42, 52 and 61). In " + "Wales and Scotland an applicant under pension age with an award of " + "Universal Credit is assessed on the Universal Credit figures, which " + "deduct relievable pension contributions in full (WSI 2013/3029 Sch " + "6 para 9; SSI 2021/249 regs 49(6)(a) and 51(2)); the national " + "schemes' Universal Credit route is not modelled, so the share here " + "applies to them too." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + ) + + def formula(benunit, period, parameters): + la = parameters(period).gov.local_authorities + household = benunit.household + country = household("country", period) + local_authority = household("local_authority", period) + # The scheme follows the applicant's own family (SI 2012/2885 reg 3), + # as in simulated_council_tax_reduction_benunit. + has_pensioner = benunit("council_tax_reduction_pensioner", period) + + def rate(ctr): + return ctr.means_test.pension_contribution_deduction_rate + + return select( + [ + is_england_pensioner_scheme(country, has_pensioner), + is_scotland_scheme(country), + is_wales_scheme(country), + is_merton_working_age(local_authority, country, has_pensioner), + is_kingston_upon_thames_working_age( + local_authority, country, has_pensioner + ), + is_newham_working_age(local_authority, country, has_pensioner), + is_westminster_working_age(local_authority, country, has_pensioner), + is_oxford_working_age(local_authority, country, has_pensioner), + ], + [ + rate(la.england.council_tax_reduction.pensioners), + rate(la.scotland.council_tax_reduction), + rate(la.wales.council_tax_reduction), + rate(la.merton.council_tax_reduction), + rate(la.kingston_upon_thames.council_tax_reduction), + rate(la.newham.council_tax_reduction), + rate(la.westminster.council_tax_reduction), + rate(la.oxford.council_tax_reduction), + ], + default=rate(la.england.council_tax_reduction.default_scheme), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index 17e26734c0..2daf943eae 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -17,9 +17,11 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "disregards and the additional amount. The amounts and work " "conditions are those of the Housing Benefit pension-age schedule " "(SI 2006/214 Sch 4), and an employee's actual earnings have the same " - "deductions (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 para " - "13, SSI 2012/319 reg 33), so the Housing Benefit net earnings and " - "work conditions are used. The notional tax on estimated earnings " + "kinds of deduction (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 " + "para 13, SSI 2012/319 reg 33), so the Housing Benefit work " + "conditions and earnings rules are used, with the scheme's own share " + "of pension contributions (council_tax_reduction_net_earnings). The " + "notional tax on estimated earnings " "and on self-employed profit can differ (Scotland deducts only the " "personal allowance, SSI 2012/319 regs 33(4) and 37(1)); the model " "applies the Housing Benefit approximation to all of them. The earnings test leaves out " @@ -61,7 +63,7 @@ def national(amount): default=england[amount], ) - net_earnings = benunit("housing_benefit_net_earnings", period) + net_earnings = benunit("council_tax_reduction_net_earnings", period) # SI 2012/2885 Sch 4 paras 2 and 8; WSI 2013/3029 Sch 3 paras 2 and 8; # SSI 2012/319 Sch 2 paras 2 and 8. A lone parent has £25 in all, # not £25 plus para 8's £5, as for Housing Benefit. From f8b8459d91f0e286cad7db97dd175dadb559936b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:57:31 -0400 Subject: [PATCH 130/224] Compare war pension income with HB only where neither credit is due With a savings-credit-only award, Housing Benefit takes the Secretary of State's assessment plus the savings credit (HB (SPC) Regs 2006 reg 27), so the differential now excludes those points. Drop the assertion that every drawn carer is paid a carer's benefit: since #1994, State Pension and other overlapping benefits can reduce Carer's Allowance to nil. On the derandomized draws the differential compares 278 of 588 points, 214 with counted war pension income above the disregard. All four properties pass in 75 s. Co-Authored-By: Claude Opus 5.5 --- .../test_pension_credit_war_pension_properties.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py index 54b8f11291..bb9869283c 100644 --- a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -13,8 +13,10 @@ and with a carer receiving Carer's Allowance or Carer Support Payment in some families (the sources #1952 added): -1. Differential with Housing Benefit. Where Guarantee Credit is not paid and - neither income floor binds, Housing Benefit applicable income plus its +1. Differential with Housing Benefit. Where neither Guarantee Credit nor + Savings Credit is due (with a savings-credit-only award Housing Benefit + uses the Pension Credit assessment plus the savings credit, HB (SPC) Regs + 2006 reg 27) and neither income floor binds, Housing Benefit applicable income plus its income disregard equals Pension Credit income less the counted war pension income. The difference is intended: the Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg @@ -191,16 +193,17 @@ def test_pension_credit_and_housing_benefit_differ_only_by_war_pensions( ): get = pension_grid(families) gc = get("guarantee_credit") + sc = get("savings_credit") hb_income = get("housing_benefit_applicable_income") hb_eligible = get("housing_benefit_eligible").astype(bool) pc_income = get("pension_credit_income") war = get("pension_credit_war_pension_income") before_disregard = hb_income + get("housing_benefit_applicable_income_disregard") - carer_benefit = get("carers_allowance") + get("carer_support_payment") for i, fam in enumerate(families): - if fam["carer"] is not None: - assert np.all(carer_benefit[i] > 0), fam - compared = (gc[i] <= 0) & hb_eligible[i] & (hb_income[i] > 0) + # Where neither credit is due: with a savings-credit-only award, + # Housing Benefit takes the Secretary of State's assessment plus the + # savings credit (HB (SPC) Regs 2006 reg 27), by design. + compared = (gc[i] <= 0) & (sc[i] <= 0) & hb_eligible[i] & (hb_income[i] > 0) compared &= pc_income[i] > 0 assert np.allclose( before_disregard[i][compared], From 955329b86cb12d2875d62ac643dc3fe1a2d0115a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:59:36 -0400 Subject: [PATCH 131/224] Correct a YAML comment: Case 11 asserts the new variable, so it fails on main Co-Authored-By: Claude Opus 5.5 --- .../dwp/pension_credit/pension_credit_war_pension_income.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml index fef73ee0e7..4c54603f77 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -299,7 +299,7 @@ country: ENGLAND output: # Reg 15(1)(n) excepts bereavement support payment. Unchanged behaviour: - # passes on the base too. + # pension_credit_income and guarantee_credit are the same on main. pension_credit_war_pension_income: 0 pension_credit_income: 0 guarantee_credit: 238 * 52 From 899183476497e05d4f536ceb5ec6191bf4c83136 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 18:42:11 -0400 Subject: [PATCH 132/224] Read Carer's Allowance's pension contribution share from a parameter carers_allowance_earnings deducted a literal 0.5 of pension contributions. The Computation of Earnings Regulations 1996 set that one-half in regs. 10(4)(b) (employed earners) and 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners), unchanged since they came into force on 25 November 1996; Northern Ireland's SR 1996/520 says the same. Add gov.dwp.carers_allowance.pension_contribution_deduction_rate (0.5 from 1996-11-25) citing them and read it in place of the literal, so the share can be reformed. Current-law outputs are unchanged. YAML cases show a reform of the share moving earnings by contributions x the change in share and, through the earnings limit, entitlement; a Hypothesis test states the exactness, locality and eligibility invariants. Fixes #2517 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2517.fixed.md | 1 + .../pension_contribution_deduction_rate.yaml | 17 ++ ...e_pension_contribution_deduction_rate.yaml | 119 ++++++++++++ ..._contribution_deduction_rate_properties.py | 172 ++++++++++++++++++ .../gov/dwp/carers_allowance_earnings.py | 15 +- 5 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2517.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml create mode 100644 policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py diff --git a/changelog.d/2517.fixed.md b/changelog.d/2517.fixed.md new file mode 100644 index 0000000000..ce59fe4bc0 --- /dev/null +++ b/changelog.d/2517.fixed.md @@ -0,0 +1 @@ +- Carer's Allowance earnings read their pension contribution deduction share (one-half, Computation of Earnings Regulations 1996 regs. 10(4)(b) and 13) from a new parameter, `gov.dwp.carers_allowance.pension_contribution_deduction_rate`, instead of a hard-coded 0.5, so the share can be reformed. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0f6b963c1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml @@ -0,0 +1,17 @@ +description: Carer's Allowance deducts this share of pension contributions when it calculates the net earnings and net profit that the earnings limit tests. +values: + 1996-11-25: 0.5 +metadata: + period: year + unit: /1 + label: Carer's Allowance earnings pension contribution deduction rate + economy: false + reference: + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/13 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/13 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..967954a67b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml @@ -0,0 +1,119 @@ +# Carer's Allowance net earnings deduct one-half of pension contributions +# (Computation of Earnings Regulations 1996 reg. 10(4)(b)) and of pension +# premiums from trading profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). The +# share is gov.dwp.carers_allowance.pension_contribution_deduction_rate. Rates +# other than 0.5 below are model reforms, not current law. +# +# 2026-27: personal allowance, NI primary threshold and Class 4 lower profits +# limit £12,570; Class 4 main rate 6%; basic rate 20%. The earnings limit is +# £204 a week (£10,608 a year) and Carer's Allowance is £86.45 a week. Pay of +# £12,000 is under the allowance and the threshold, so no tax or Class 1 is +# due on it. + +- name: At current law Carer's Allowance earnings deduct half of pension contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 3,000 = 10,500, or £201.92 a week: under the limit. + pension_contributions: 3_000 + ni_class_1_employee: 0 + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises earnings by contributions times the change and ends entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.25 x 3,000 = 11,250, or £216.35 a week: over the limit. + # That is 750 more than at half: 3,000 x (0.5 - 0.25). + carers_allowance_earnings: 11_250 + is_gainfully_employed_for_carers_allowance: true + carers_allowance_pre_overlap: 0 + carers_allowance: 0 + +- name: At current law a carer with smaller contributions is over the earnings limit + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 2,000 = 11,000, or £211.54 a week: over the limit. + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A higher share lowers earnings by contributions times the change and restores entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.75 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.75 x 2,000 = 10,500, or £201.92 a week: under the limit. + # That is 500 less than at half: 2,000 x (0.75 - 0.5). + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises trading earnings by premiums times the change + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.4 + age: 45 + self_employment_income: 15_000 + personal_pension_contributions: 7_700 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 20% x 2,430 = 486 and Class 4 6% x 2,430 = 145.80 (reg. + # 14). At half: 15,000 - 486 - 145.80 - 3,850 = 10,518.20, or £202.27 a + # week, under the limit. At 0.4: 15,000 - 486 - 145.80 - 3,080 = + # 11,288.20, or £217.08 a week, over it: 770 more, 7,700 x (0.5 - 0.4). + carers_allowance_earnings: 11_288.20 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A reform of the Housing Benefit share leaves Carer's Allowance earnings + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # Housing Benefit net earnings: 12,000 - 0.25 x 3,000 = 11,250. Carer's + # Allowance keeps its own half: 12,000 - 0.5 x 3,000 = 10,500. + housing_benefit_net_earnings: 11_250 + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 diff --git a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py new file mode 100644 index 0000000000..2f5b90c32b --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py @@ -0,0 +1,172 @@ +"""Property-based tests for Carer's Allowance's pension contribution share. + +The Carer's Allowance earnings limit tests net earnings under the Social +Security Benefit (Computation of Earnings) Regulations 1996, which deduct one +half of pension contributions from an employed earner's net earnings (reg. +10(4)(b)) and one half of pension premiums from a self-employed earner's net +profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). Northern Ireland's 1996 +Regulations (SR 1996/520) say the same. The model reads the share from +gov.dwp.carers_allowance.pension_contribution_deduction_rate. + +Invariants, for any generated population of working-age carers in any UK +country and year, with pay, trading profit (a loss counts as nil) and +employee and personal pension contributions of at most 30% of earnings, so +that no floor binds: + +1. Exactness: when the share moves from 0.5 to r, carers_allowance_earnings + falls by exactly the person's contributions x (r - 0.5). +2. Locality: the Housing Benefit share is reformed at the same time to a + different value, so earnings that read Housing Benefit's share would miss + invariant 1. +3. Eligibility follows earnings through the limit: on both sides the person + is gainfully employed exactly when weekly earnings, with a fraction of a + penny disregarded (reg. 5), exceed the limit, and then has no underlying + entitlement. A lower share can only end entitlement and a higher share can + only restore it. +4. Current law: the share is 0.5 in every year from 2015 (the first year the + model simulates) to 2030. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +CA_SHARE = "gov.dwp.carers_allowance.pension_contribution_deduction_rate" +HB_SHARE = "gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate" +# Every period the model simulates. +ALL_YEARS = "2000-01-01.2100-12-31" +COUNTRIES = ["ENGLAND", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +YEARS = [2016, 2020, 2024, 2026, 2030] +# Shares on a grid of twentieths, each exact in binary. +SHARES = [i / 20 for i in range(21) if i != 10] +TOLERANCE = 0.01 + +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def carers(draw): + pay = draw(st.floats(0, 60_000, allow_nan=False)) + profit = draw(st.floats(-20_000, 60_000, allow_nan=False)) + earnings = pay + max(0.0, profit) + # Net earnings before the pension deduction are more than 60% of + # earnings in every year and country tested (about 70% at the top of + # the range), so contributions of at most 30% leave them positive at + # any share up to one. + employee_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + personal_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + return dict( + country=draw(st.sampled_from(COUNTRIES)), + age=draw(st.integers(25, 60)), + employment_income=pay, + self_employment_income=profit, + employee_pension_contributions=round(pay * employee_share, 2), + personal_pension_contributions=round(earnings * personal_share, 2), + ) + + +@st.composite +def reforms(draw): + ca, hb = draw( + st.lists(st.sampled_from(SHARES), min_size=2, max_size=2, unique=True) + ) + return dict(ca=ca, hb=hb) + + +def situation(people, year): + persons, benunits, households = {}, {}, {} + for i, carer in enumerate(people): + name = f"carer_{i}" + persons[name] = { + key: {year: value} for key, value in carer.items() if key != "country" + } + persons[name]["care_hours"] = {year: 40} + benunits[f"b{i}"] = {"members": [name]} + households[f"h{i}"] = { + "members": [name], + "country": {year: carer["country"]}, + } + return {"people": persons, "benunits": benunits, "households": households} + + +VARIABLES = [ + "pension_contributions", + "carers_allowance_earnings", + "is_gainfully_employed_for_carers_allowance", + "carers_allowance_pre_overlap", +] + + +def calculate(people, year, reform=None): + sim = Simulation(situation=situation(people, year), reform=reform) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in VARIABLES} + + +def over_limit(earnings, limit): + # Reg. 5: a fraction of a penny is disregarded. + return np.floor(earnings / 52 * 100 + 1e-6) / 100 > limit + + +@PROPERTY_SETTINGS +@given( + st.lists(carers(), min_size=1, max_size=24), + reforms(), + st.sampled_from(YEARS), +) +def test_earnings_fall_by_contributions_times_the_change_in_share(people, shares, year): + base = calculate(people, year) + moved = calculate( + people, + year, + { + CA_SHARE: {ALL_YEARS: shares["ca"]}, + HB_SHARE: {ALL_YEARS: shares["hb"]}, + }, + ) + limit = system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit + for i, carer in enumerate(people): + case = (carer, shares, year) + contributions = ( + carer["employee_pension_contributions"] + + carer["personal_pension_contributions"] + ) + assert abs(base["pension_contributions"][i] - contributions) < TOLERANCE, case + if contributions > 0: + # Floors are slack: earnings stay positive on both sides. + assert base["carers_allowance_earnings"][i] > 0, case + assert moved["carers_allowance_earnings"][i] > 0, case + # Invariants 1 and 2: exactly contributions x the change in the + # Carer's Allowance share, not the Housing Benefit share. + change = ( + base["carers_allowance_earnings"][i] - moved["carers_allowance_earnings"][i] + ) + assert abs(change - contributions * (shares["ca"] - 0.5)) < TOLERANCE, case + # Invariant 3: the gainful-employment test follows earnings. + for values in (base, moved): + over = over_limit(values["carers_allowance_earnings"][i], limit) + gainful = bool(values["is_gainfully_employed_for_carers_allowance"][i]) + assert gainful == over, case + if gainful: + assert values["carers_allowance_pre_overlap"][i] == 0, case + was = bool(base["is_gainfully_employed_for_carers_allowance"][i]) + now = bool(moved["is_gainfully_employed_for_carers_allowance"][i]) + if shares["ca"] < 0.5: + assert now or not was, case + else: + assert was or not now, case + + +def test_the_share_is_one_half_in_every_year(): + for year in range(2015, 2031): + for day in ("01-01", "04-30"): + parameters = system.parameters(f"{year}-{day}") + share = parameters.gov.dwp.carers_allowance + assert share.pension_contribution_deduction_rate == 0.5, (year, day) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py index b49b6618d0..e3dc980d29 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -9,8 +9,10 @@ class carers_allowance_earnings(Variable): documentation = ( "Net earnings from employment and self-employment, as the Carer's " "Allowance gainful-employment test counts them (Computation of " - "Earnings Regulations 1996), less half of the person's pension " - "contributions. A trading loss counts as nil.\n\n" + "Earnings Regulations 1996), less a share of the person's pension " + "contributions: one half in law (regs. 10(4)(b) and 13(4)(c)), read " + "from gov.dwp.carers_allowance.pension_contribution_deduction_rate. " + "A trading loss counts as nil.\n\n" "Employment (reg. 10(4)): gross pay less the income tax and primary " "Class 1 contributions deducted from it. The law deducts the tax " "actually withheld; the model approximates it as the tax PAYE would " @@ -119,7 +121,12 @@ def formula(person, period, parameters): * class_2_contribution_weeks(period.start.year) ) - half_pension_contributions = 0.5 * person("pension_contributions", period) + # Regs. 10(4)(b) and 13(1)(b)(ii), (4)(c) and (10)(b): one half of + # pension contributions and premiums. + pension_share = parameters( + period + ).gov.dwp.carers_allowance.pension_contribution_deduction_rate + pension_deduction = pension_share * person("pension_contributions", period) return max_( 0, pay @@ -129,5 +136,5 @@ def formula(person, period, parameters): - trading_tax - trading_class_4 - trading_class_2 - - half_pension_contributions, + - pension_deduction, ) From b9b567144d45add85bc58ad7908dddd12e0be14d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:50:23 +0000 Subject: [PATCH 133/224] Update package version --- CHANGELOG.md | 11 +++++++++++ changelog.d/1822.fixed.md | 1 - changelog.d/1932.fixed.md | 1 - changelog.d/2071.fixed.md | 1 - changelog.d/2121.fixed.md | 1 - changelog.d/2371.fixed.md | 1 - pyproject.toml | 2 +- 7 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 changelog.d/1822.fixed.md delete mode 100644 changelog.d/1932.fixed.md delete mode 100644 changelog.d/2071.fixed.md delete mode 100644 changelog.d/2121.fixed.md delete mode 100644 changelog.d/2371.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ee089584e2..a08c0cef83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2.139.4] - 2026-10-19 + +### Fixed + +- - Corrected dividend income-tax band thresholds from 2016 to 2025 to use their statutory tax-year dates. +- - Corrected historical Care to Learn weekly caps while preserving the existing annual date approximation. +- - Exempted non-dependants responsible for a child under five from Universal Credit housing cost contributions. +- - Applied the shared Pension Credit eligibility conditions to computed Savings Credit amounts. +- - Included statutory sick and maternity pay in the relevant earnings limits for pension contribution relief and annual-allowance income tests. + + ## [2.139.3] - 2026-10-19 ### Fixed diff --git a/changelog.d/1822.fixed.md b/changelog.d/1822.fixed.md deleted file mode 100644 index 4a057f7079..0000000000 --- a/changelog.d/1822.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Corrected dividend income-tax band thresholds from 2016 to 2025 to use their statutory tax-year dates. diff --git a/changelog.d/1932.fixed.md b/changelog.d/1932.fixed.md deleted file mode 100644 index b6f715822a..0000000000 --- a/changelog.d/1932.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Corrected historical Care to Learn weekly caps while preserving the existing annual date approximation. diff --git a/changelog.d/2071.fixed.md b/changelog.d/2071.fixed.md deleted file mode 100644 index 84ff5437b4..0000000000 --- a/changelog.d/2071.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Exempted non-dependants responsible for a child under five from Universal Credit housing cost contributions. diff --git a/changelog.d/2121.fixed.md b/changelog.d/2121.fixed.md deleted file mode 100644 index 5916ccce06..0000000000 --- a/changelog.d/2121.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Applied the shared Pension Credit eligibility conditions to computed Savings Credit amounts. diff --git a/changelog.d/2371.fixed.md b/changelog.d/2371.fixed.md deleted file mode 100644 index 204233b876..0000000000 --- a/changelog.d/2371.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Included statutory sick and maternity pay in the relevant earnings limits for pension contribution relief and annual-allowance income tests. diff --git a/pyproject.toml b/pyproject.toml index f6b98c559a..9098d0b074 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.139.3" +version = "2.139.4" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From c1a6f3a0c96b127b9d9a728a4d4fac6cea8d403c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 21:57:17 -0400 Subject: [PATCH 134/224] Keep the war widow's pension aggregate test outside the means tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_war_widows_pension_counts_wherever_bsp_does (#2382) checked that adding £5,000 of war widow's pension raises household_benefits, HBAI net income and government spending by £5,000, as BSP does. Its widow was on Pension Credit, and war widow's pension is now Pension Credit income (SPCA 2002 s.15(1)(g)) while BSP is not (reg 15(1)(n)), so her Pension Credit fell and the totals rose by £520. Give her £40,000 of private pension, which keeps her out of every means test, so the test again measures how the aggregates count the payment. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_war_widows_pension.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_war_widows_pension.py b/policyengine_uk/tests/test_war_widows_pension.py index 2356787ed0..8d12f38e13 100644 --- a/policyengine_uk/tests/test_war_widows_pension.py +++ b/policyengine_uk/tests/test_war_widows_pension.py @@ -52,10 +52,21 @@ def test_war_widows_pension_reported_declares_cpi_uprating(): @pytest.mark.parametrize("aggregate", AGGREGATES_THAT_COUNT_BSP) def test_war_widows_pension_counts_wherever_bsp_does(aggregate): + # £40,000 of private pension keeps the widow out of every means test, so + # each aggregate moves by the payment itself. War widow's pension is + # Pension Credit income (SPCA 2002 s.15(1)(g)) and BSP is not (SPC Regs + # 2002 reg 15(1)(n)), so for a widow on Pension Credit the two would + # change the totals by different amounts. def value(person_inputs): sim = Simulation( situation={ - "people": {"widow": {"age": 70, **person_inputs}}, + "people": { + "widow": { + "age": 70, + "private_pension_income": {2026: 40_000}, + **person_inputs, + } + }, "benunits": {"benunit": {"members": ["widow"]}}, "households": {"household": {"members": ["widow"]}}, } From b96ec3fcb40124e0b8510e813e9039b1937b16bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 21:57:51 -0400 Subject: [PATCH 135/224] Use an untaxed carer for the Housing Benefit locality case The case paid Carer's Allowance, which is taxable, so Housing Benefit net earnings carried a share of the resulting income tax (134.64) that the expected value left out. Use the over-limit carer instead: no allowance is paid and no tax is due, so Housing Benefit net earnings are 12,000 less a quarter of 2,000. Co-Authored-By: Claude Opus 5.5 --- ...e_pension_contribution_deduction_rate.yaml | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml index 967954a67b..723a7a972b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml @@ -7,8 +7,8 @@ # 2026-27: personal allowance, NI primary threshold and Class 4 lower profits # limit £12,570; Class 4 main rate 6%; basic rate 20%. The earnings limit is # £204 a week (£10,608 a year) and Carer's Allowance is £86.45 a week. Pay of -# £12,000 is under the allowance and the threshold, so no tax or Class 1 is -# due on it. +# £12,000 is under the allowance and the threshold, so the earnings test +# deducts no tax (it takes PAYE on the pay alone) and no Class 1. - name: At current law Carer's Allowance earnings deduct half of pension contributions period: 2026 @@ -107,13 +107,17 @@ gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 age: 45 employment_income: 12_000 - personal_pension_contributions: 3_000 + personal_pension_contributions: 2_000 care_hours: 40 country: ENGLAND output: - # Housing Benefit net earnings: 12,000 - 0.25 x 3,000 = 11,250. Carer's - # Allowance keeps its own half: 12,000 - 0.5 x 3,000 = 10,500. - housing_benefit_net_earnings: 11_250 - carers_allowance_earnings: 10_500 - is_gainfully_employed_for_carers_allowance: false - carers_allowance: 86.45 * 52 + # The carer is over the limit, so no Carer's Allowance is paid and the + # £12,000 of pay, less relief on the contributions, is the only income: + # no income tax. Housing Benefit net earnings: 12,000 - 0.25 x 2,000 = + # 11,500. Carer's Allowance keeps its own half: 12,000 - 0.5 x 2,000 = + # 11,000, still £211.54 a week. + income_tax: 0 + housing_benefit_net_earnings: 11_500 + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 From 565fabef3f4cf942efdfa986b846af1af89b0aef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:01:30 -0400 Subject: [PATCH 136/224] Read the cohort from the claimant and partner only (review P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule 3 para 1 of SI 2006/214 reads the claimant's and partner's ages, and SI 2012/2885 Sch 2 para 1 the CTR applicant's and partner's. The selector scanned every benefit-unit member, so an older non-claimant in the unit gave a later-cohort claimant the higher rate (£13,312 instead of £12,376 a year). - housing_benefit_attained_pension_age_before_cutoff now reads is_claimant_or_partner. - New council_tax_reduction_attained_pension_age_before_cutoff reads is_council_tax_reduction_applicant_or_partner, as the CTR pensioner test does; the CTR applicable amount uses it. - YAML regression cases for HB and England CTR (they fail without the fix), and a Hypothesis invariant: adding a member who is neither, of any age, never changes either allowance. Co-Authored-By: Claude Opus 5.5 --- ...enefit_pension_age_personal_allowance.yaml | 33 ++++++++++ ...uction_pension_age_personal_allowance.yaml | 31 ++++++++++ ...est_housing_benefit_pension_age_cohorts.py | 62 +++++++++++++++++++ ...efit_attained_pension_age_before_cutoff.py | 21 ++++--- ...council_tax_reduction_applicable_amount.py | 2 +- ...tion_attained_pension_age_before_cutoff.py | 35 +++++++++++ 6 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml index 9150b62ff1..213e6a0c50 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -314,3 +314,36 @@ housing_benefit_attained_pension_age_before_cutoff: false housing_benefit_applicable_amount: 238 * 52 housing_benefit: 2_810.30 + +- name: An older non-claimant in the benefit unit does not change the claimant's cohort + # Schedule 3 para 1 reads the claimant's and partner's ages. The claimant + # (born 1 January 1959) attained State Pension age in 2025, after the + # cutoff; the 80-year-old is not the claimant or partner, so the lower + # single rate applies: 238 x 52. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 67 + date_of_birth: 19590101 + is_claimant_or_partner: true + is_household_head: true + other_adult: + age: 80 + date_of_birth: 19460101 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + pension_age_benefits_premiums: 0 + households: + home: + members: [claimant, other_adult] + country: ENGLAND + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml index 60cd801878..2679c0baa8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -158,3 +158,34 @@ region: EAST_MIDLANDS output: council_tax_reduction_applicable_amount: 363.25 * 52 + +- name: In England, an older non-applicant does not change the applicant's cohort + # SI 2012/2885 Sch 2 para 1 reads the applicant's and partner's ages. The + # applicant attained State Pension age in 2025, after the cutoff, so the + # lower single rate applies: 238 x 52. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 67 + date_of_birth: 19590101 + is_claimant_or_partner: true + is_household_head: true + other_adult: + age: 80 + date_of_birth: 19460101 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + council_tax_reduction_pensioner: true + benefits_premiums: 0 + pension_age_benefits_premiums: 0 + households: + home: + members: [claimant, other_adult] + country: ENGLAND + output: + council_tax_reduction_attained_pension_age_before_cutoff: false + council_tax_reduction_applicable_amount: 238 * 52 diff --git a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py index 42e1d54d7c..3334321751 100644 --- a/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -29,6 +29,8 @@ amounts (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29), so its allowance equals Housing Benefit's; the Scottish and Welsh schemes have one pension-age rate, the higher one. +7. Only the claimant and partner (HB) or the applicant and partner (CTR) + count: adding another member of any age never changes either allowance. """ from datetime import date, timedelta @@ -247,3 +249,63 @@ def test_higher_rates_match_the_statute(year): assert P.single.aged(at) == pytest.approx(single, abs=1e-6) assert P.lone_parent.aged(at) == pytest.approx(single, abs=1e-6) assert P.couple.aged(at) == pytest.approx(couple, abs=1e-6) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=15), + others=st.lists(MEMBER, min_size=15, max_size=15), + year=st.sampled_from(YEARS), +) +def test_other_members_do_not_change_the_cohort(units, others, year): + """Invariant 7: the cohort reads only the claimant and partner (HB) or + the applicant and partner (England CTR). Adding a member who is neither, + of any age, never changes either allowance.""" + plain = build(units, year) + people, benunits = {}, {} + mid_year = grid_months(date(year, 10, 6)) + for u, members in enumerate(units): + names = [] + for k, (birth, male) in enumerate(members + [others[u]]): + age_in_months = mid_year - grid_months(birth) + age = int(age_in_months // 12) + name = f"u{u}p{k}" + people[name] = { + "age": {year: age}, + "months_since_last_birthday": {year: age_in_months - 12 * age}, + "is_male": {year: male}, + "is_claimant_or_partner": {year: k < len(members)}, + "is_council_tax_reduction_applicant_or_partner": { + year: k < len(members) + }, + } + names.append(name) + pension_age = at_pension_age(members, year) + benunits[f"u{u}"] = { + "members": names, + "housing_benefit_eligible": {year: True}, + "housing_benefit_pension_age_regulations_apply": {year: pension_age}, + "council_tax_reduction_pensioner": {year: pension_age}, + "benefits_premiums": {year: 0}, + } + with_others = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"h": {"members": list(people), "region": {year: "LONDON"}}}, + } + ) + for variable in ( + "housing_benefit_applicable_amount", + "council_tax_reduction_applicable_amount", + ): + np.testing.assert_allclose( + with_others.calculate(variable, year), + plain.calculate(variable, year), + atol=0.005, + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py index 6869eac8b3..4ef0a58602 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py @@ -4,12 +4,13 @@ class housing_benefit_attained_pension_age_before_cutoff(Variable): value_type = bool entity = BenUnit - label = "a member attained State Pension age before the Housing Benefit cutoff" + label = "claimant or partner attained State Pension age before the Housing Benefit cutoff" documentation = ( - "Whether any member of the benefit unit attained State Pension age " - "before the pension-age cutoff (1 April 2021). Such units keep the " - "higher pension-age personal allowance; units whose members all " - "attained it on or after the cutoff get the lower one. Before the " + "Whether the Housing Benefit claimant or their partner attained State " + "Pension age before the pension-age cutoff (1 April 2021). Such units " + "keep the higher pension-age personal allowance; units where neither " + "did get the lower one. Other members of the benefit unit do not " + "count. Before the " "cutoff took effect there was a single pension-age allowance, the " "higher one, so this is true for every unit then." ) @@ -45,5 +46,11 @@ def formula(benunit, period, parameters): # # Dates are compared as YYYYMMDD integers, which are exact; as float32 # they would not be (20210401 would round to 20210400). - attained = benunit.members("state_pension_age_date", period) - return benunit.any(attained < cutoff) + # + # Schedule 3 para 1 reads the claimant's and partner's ages ("the + # claimant", "one member or both members" of a couple), so other + # members of the benefit unit do not count. + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + attained = person("state_pension_age_date", period) + return benunit.any(claimant_or_partner & (attained < cutoff)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index 87f1f58ace..a9cdedf417 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -54,7 +54,7 @@ def formula(benunit, period, parameters): if p.pension_age_cutoff is not None: in_england = benunit.household("country", period) == Country.ENGLAND lower = in_england & ~benunit( - "housing_benefit_attained_pension_age_before_cutoff", period + "council_tax_reduction_attained_pension_age_before_cutoff", period ) single_aged = where(lower, p.single.aged_from_cutoff, single_aged) couple_aged = where(lower, p.couple.aged_from_cutoff, couple_aged) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py new file mode 100644 index 0000000000..6e2374f16d --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_attained_pension_age_before_cutoff(Variable): + value_type = bool + entity = BenUnit + label = "CTR applicant or partner attained State Pension age before the cutoff" + documentation = ( + "Whether the Council Tax Reduction applicant or their partner attained " + "State Pension age before 1 April 2021, the cutoff England's pensioner " + "scheme shares with Housing Benefit. Such applicants keep the higher " + "pension-age personal allowance; other members of the household do " + "not count. Before the cutoff took effect there was a single " + "pension-age allowance, the higher one, so this is true for every unit " + "then." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2" + + def formula(benunit, period, parameters): + cutoff = parameters( + period + ).gov.dwp.housing_benefit.allowances.pension_age_cutoff + if cutoff is None: + return np.ones(benunit.count, dtype=bool) + # SI 2012/2885 Sch 2 para 1 reads the applicant's and partner's ages, + # the same people the pensioner test reads + # (is_council_tax_reduction_applicant_or_partner). Dates are compared + # as exact YYYYMMDD integers. + person = benunit.members + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + attained = person("state_pension_age_date", period) + return benunit.any(applicant_or_partner & (attained < cutoff)) From cc86a337a958737465f389d0885669264a12ff13 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:03:34 -0400 Subject: [PATCH 137/224] Compare the float32 share within a tolerance in the property test The model holds council_tax_reduction_pension_contribution_deduction_rate as float32, so a 0.05 share reads back as 0.0500000007 and the exact comparison failed in CI (Test / Python 2/4). Compare within 1e-6 and drop the comment's wrong claim that twentieths are exact in binary. Co-Authored-By: Claude Opus 5.5 --- ...test_pension_contribution_deduction_rate_properties.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py index 45310d1403..51a613efbd 100644 --- a/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py +++ b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py @@ -94,10 +94,12 @@ # pension age and 55 and under working age. WORKING_AGE = (25, 55) PENSION_AGE = (68, 90) -# Shares other than 0.5, on a grid of twentieths so that each is exact in -# binary and every pair differs by at least 0.05. +# Shares other than 0.5, on a grid of twentieths so that every pair differs +# by at least 0.05. The model holds the share as float32 (0.05 reads back as +# 0.0500000007), so shares are compared within RATE_TOLERANCE. SHARES = [i / 20 for i in range(21) if i != 10] TOLERANCE = 0.05 +RATE_TOLERANCE = 1e-6 @st.composite @@ -209,7 +211,7 @@ def test_each_scheme_moves_by_contributions_times_its_own_change_in_share( assert values["income_support_applicable_income"][i] > 0, case rate = "council_tax_reduction_pension_contribution_deduction_rate" assert base[rate][i] == 0.5, case - assert moved[rate][i] == shares[scheme], case + assert abs(moved[rate][i] - shares[scheme]) < RATE_TOLERANCE, case # Invariant 1 (with 2): exactly contributions x the scheme's change. for variable, share in ( ("council_tax_reduction_applicable_income", shares[scheme]), From 99d9703f23569fce3b687990e560a5bf2669691b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:10:45 -0400 Subject: [PATCH 138/224] Count pence in the model's float32 in the eligibility property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property test promoted carers_allowance_earnings to float64 before repeating the formula's penny rounding. The model stores earnings as float32, so 10,608.52 a year is stored as 10,608.51953125: in float32 the formula counts 20,401 weekly pence (over the £204 limit), but the float64 recomputation floors to 20,400 and expected not gainfully employed. Keep the stored dtype for the rounding check, pin that boundary as an explicit Hypothesis example, and widen the exactness tolerance to 0.05 for float32 spacing at the largest earnings drawn. Found by the GPT-6.1 Sol review. Co-Authored-By: Claude Opus 5.5 --- ..._contribution_deduction_rate_properties.py | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py index 2f5b90c32b..be9c21dea3 100644 --- a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py @@ -21,14 +21,15 @@ 3. Eligibility follows earnings through the limit: on both sides the person is gainfully employed exactly when weekly earnings, with a fraction of a penny disregarded (reg. 5), exceed the limit, and then has no underlying - entitlement. A lower share can only end entitlement and a higher share can + entitlement. The pence are counted in the precision the model stores + earnings in (float32), as the formula counts them. A lower share can only end entitlement and a higher share can only restore it. 4. Current law: the share is 0.5 in every year from 2015 (the first year the model simulates) to 2030. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -42,7 +43,10 @@ YEARS = [2016, 2020, 2024, 2026, 2030] # Shares on a grid of twentieths, each exact in binary. SHARES = [i / 20 for i in range(21) if i != 10] -TOLERANCE = 0.01 +# The model stores amounts as float32, whose spacing is about 0.008 at the +# largest earnings drawn (up to 120,000), so two stored values can differ from +# the exact difference by more than a penny. +TOLERANCE = 0.05 PROPERTY_SETTINGS = settings( max_examples=6, @@ -107,20 +111,38 @@ def situation(people, year): def calculate(people, year, reform=None): sim = Simulation(situation=situation(people, year), reform=reform) - return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in VARIABLES} + # Keep each variable's stored dtype: the rounding check below must count + # pence in the precision the formula does. + return {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} def over_limit(earnings, limit): - # Reg. 5: a fraction of a penny is disregarded. + # Reg. 5: a fraction of a penny is disregarded. ``earnings`` is the stored + # array, 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 / 52 * 100 + 1e-6) / 100 > limit +# A carer one penny a week over the 2026-27 limit (10,608.52 / 52 = 204.01), on +# the float32 boundary described in over_limit. +PENNY_OVER = dict( + country="ENGLAND", + age=45, + employment_income=10_608.52, + self_employment_income=0.0, + employee_pension_contributions=0.0, + personal_pension_contributions=0.0, +) + + @PROPERTY_SETTINGS @given( - st.lists(carers(), min_size=1, max_size=24), - reforms(), - st.sampled_from(YEARS), + people=st.lists(carers(), min_size=1, max_size=24), + shares=reforms(), + year=st.sampled_from(YEARS), ) +@example(people=[PENNY_OVER], shares=dict(ca=0.25, hb=0.75), year=2026) def test_earnings_fall_by_contributions_times_the_change_in_share(people, shares, year): base = calculate(people, year) moved = calculate( @@ -138,16 +160,16 @@ def test_earnings_fall_by_contributions_times_the_change_in_share(people, shares carer["employee_pension_contributions"] + carer["personal_pension_contributions"] ) - assert abs(base["pension_contributions"][i] - contributions) < TOLERANCE, case + stored = float(base["pension_contributions"][i]) + assert abs(stored - contributions) < TOLERANCE, case + before = float(base["carers_allowance_earnings"][i]) + after = float(moved["carers_allowance_earnings"][i]) if contributions > 0: # Floors are slack: earnings stay positive on both sides. - assert base["carers_allowance_earnings"][i] > 0, case - assert moved["carers_allowance_earnings"][i] > 0, case + assert before > 0 and after > 0, case # Invariants 1 and 2: exactly contributions x the change in the # Carer's Allowance share, not the Housing Benefit share. - change = ( - base["carers_allowance_earnings"][i] - moved["carers_allowance_earnings"][i] - ) + change = before - after assert abs(change - contributions * (shares["ca"] - 0.5)) < TOLERANCE, case # Invariant 3: the gainful-employment test follows earnings. for values in (base, moved): From caceb5540ad6a023c3479d4723449e5a4b44cfb2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:11:39 -0400 Subject: [PATCH 139/224] Describe the cutoff in terms of the claimant and partner (review P3) Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml index 990ae7995c..73f8ac7820 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml @@ -1,4 +1,4 @@ -description: Since April 2021, pension-age Housing Benefit personal allowances depend on when members of the benefit unit attained State Pension age. The higher allowances apply if any member attained it before this date, written as a YYYYMMDD number; the lower allowances apply if every member attained it on or after this date. +description: Since April 2021, pension-age Housing Benefit personal allowances depend on when the claimant and any partner attained State Pension age. The higher allowances apply if either attained it before this date, written as a YYYYMMDD number; the lower allowances apply if both (or a single claimant) attained it on or after this date. Other members of the benefit unit do not count. values: 2021-04-01: 20210401 metadata: From 794991569da5c8de2dd4b2ac021814afe7e6a746 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:11:54 -0400 Subject: [PATCH 140/224] Say 'otherwise' for the lower rate, which also covers mixed-age couples Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml index 73f8ac7820..0f78e4b9b5 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml @@ -1,4 +1,4 @@ -description: Since April 2021, pension-age Housing Benefit personal allowances depend on when the claimant and any partner attained State Pension age. The higher allowances apply if either attained it before this date, written as a YYYYMMDD number; the lower allowances apply if both (or a single claimant) attained it on or after this date. Other members of the benefit unit do not count. +description: Since April 2021, pension-age Housing Benefit personal allowances depend on when the claimant and any partner attained State Pension age. The higher allowances apply if the claimant or partner attained it before this date, written as a YYYYMMDD number; otherwise the lower allowances apply. Other members of the benefit unit do not count. values: 2021-04-01: 20210401 metadata: From 4c1cc3759d336c3d5c5d93ecdc50d99be41bc5ee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:18:45 -0400 Subject: [PATCH 141/224] Keep war pensions out of savings-credit-only HB and CTR; review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of #2508 found that the savings-credit-only routes of Housing Benefit (HB (SPC) Regs 2006 reg 27) and Council Tax Reduction take pension_credit_income, which now counts war pensions less £10, while their general routes count none. Under the model's assumption that authorities disregard war pensions in full under SSAA 1992 s.134(8) (HB/CTB Guidance Manual P2.576: an authority "may modify The Pension Service's calculation" for a greater war pension disregard), both routes now remove pension_credit_war_pension_income. Also: - YAML Case 14: a savings-credit-only claimant with an AFCS payment; HB and CTR income 13,954 - 1,040 + 302.616 = 13,216.616. - Property 5: with and without the payments, HB and CTR income change only by the change in Pension Credit paid, on both routes. Property 3 now starts from nil private pension so Guarantee Credit is usually paid. - Document that afcs sums AFCS payments and war disablement pensions (one £10 on the sum) and that Sch IV para 1(cc)'s abated-GIP rule is not modelled; quote the s.17(1) definition of war widow's pension fully. - Drop three YAML comments that said a case passes on main; name guaranteed income payments in the changelog. Co-Authored-By: Claude Opus 5.5 --- .../pension-credit-war-pensions.fixed.md | 2 +- .../pension_credit_war_pension_income.yaml | 48 +++++++++++++- ...t_pension_credit_war_pension_properties.py | 63 +++++++++++++++++-- ...sing_benefit_savings_credit_only_income.py | 20 ++++-- .../pension_credit_war_pension_income.py | 26 +++++--- .../variables/gov/dwp/war_widows_pension.py | 5 +- ...council_tax_reduction_applicable_income.py | 9 ++- 7 files changed, 144 insertions(+), 29 deletions(-) diff --git a/changelog.d/pension-credit-war-pensions.fixed.md b/changelog.d/pension-credit-war-pensions.fixed.md index abcdc35a3d..865116033e 100644 --- a/changelog.d/pension-credit-war-pensions.fixed.md +++ b/changelog.d/pension-credit-war-pensions.fixed.md @@ -1 +1 @@ -- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). +- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme guaranteed income payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Housing Benefit and Council Tax Reduction continue to disregard them in full, including where they take the Pension Credit assessment of a savings-credit-only award. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml index 4c54603f77..b8d49ad37f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -63,7 +63,7 @@ members: [person1] country: ENGLAND output: - # max(5 - 10, 0) * 52 = 0. Unchanged behaviour: passes on the base too. + # max(5 - 10, 0) * 52 = 0. pension_credit_war_pension_income: 0 pension_credit_income: 0 guarantee_credit: 238 * 52 @@ -85,7 +85,7 @@ members: [person1] country: ENGLAND output: - # max(10 - 10, 0) * 52 = 0. Unchanged behaviour: passes on the base too. + # max(10 - 10, 0) * 52 = 0. pension_credit_war_pension_income: 0 pension_credit_income: 0 guarantee_credit: 238 * 52 @@ -249,7 +249,6 @@ output: is_claimant_or_partner: [true, true, false, false] # Only the claimant's and partner's payments count (SPCA s.5; reg 14). - # Unchanged behaviour: passes on the base too. pension_credit_war_pension_income: 0 pension_credit_income: 0 @@ -355,3 +354,46 @@ pension_credit_war_pension_income: 20 * 52 pension_credit_income: 20 * 52 guarantee_credit: (238 - 20) * 52 + +- name: Case 14, savings-credit-only Housing Benefit and CTR do not count an AFCS payment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 250 * 52 + afcs: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + council_tax: 1_500 + output: + # Income tax on the private pension: (13,000 - 12,570) * 0.2 = 86; the AFCS + # payment is not taxable income. + income_tax: 86 + pension_credit_war_pension_income: (30 - 10) * 52 + # 13,000 + 1,040 - 86 = 13,954, above the 12,376 minimum guarantee. + pension_credit_income: 13_954 + guarantee_credit: 0 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (13,954 / 52 - 208.07)) * 52 + # = 0.6 * 29.93 * 52 = 933.816; amount B = 0.4 * (13,954 - 12,376) + # = 631.20; savings credit = 302.616, so the award is savings + # credit only. + savings_credit: 302.616 + # HB (SPC) Regs 2006 reg 27: the Secretary of State's assessment plus the + # savings credit payable. The model takes the authority to disregard war + # pensions in full (SSAA 1992 s.134(8); HB/CTB Guidance Manual P2.576), so + # the 1,040 counted for Pension Credit comes out: 13,954 - 1,040 + 302.616 + # = 13,216.616. There are no earnings, so no earnings disregard. + housing_benefit_applicable_income: 13_216.616 + # The CTR pensioner scheme's savings-credit-only route, the same way. + council_tax_reduction_applicable_income: 13_216.616 diff --git a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py index bb9869283c..122cfcb68e 100644 --- a/policyengine_uk/tests/test_pension_credit_war_pension_properties.py +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -24,7 +24,9 @@ Social Security Administration Act 1992 s.134(8) lets an authority modify its scheme to disregard "the whole or part" of a prescribed war disablement or war widow's pension, and `housing_benefit_applicable_income` - counts neither `afcs` nor `war_widows_pension`. That is an assumption that + counts neither `afcs` nor `war_widows_pension` (on the savings-credit-only + route it removes the war pension income the Pension Credit assessment + counts; see invariant 5). That is an assumption that the authority runs such a local scheme with a full disregard; it is not a statement about any particular authority. The draws have no earnings, no pension contributions and capital of at most £10,000, so neither tariff @@ -39,6 +41,14 @@ claimant's and partner's payments only as a collection: reassigning the amounts between the two sources and between the claimant and partner changes neither, because each payment has its own £10 a week. +5. Housing Benefit and Council Tax Reduction disregard war pensions in full on + every route. Comparing each family with and without its payments, where + neither run pays Guarantee Credit, both runs take the same route + (savings-credit-only or general) and neither income floor binds, Housing + Benefit applicable income and Council Tax Reduction applicable income + change by exactly the change in Pension Credit paid: on the + savings-credit-only route they add the savings credit payable (HB (SPC) + Regs 2006 reg 27), which the payments reduce, and nothing else. """ import numpy as np @@ -82,9 +92,9 @@ def weekly_payment(): @st.composite -def adult(draw): +def adult(draw, min_age=67): return dict( - age=draw(st.integers(67, 95)), + age=draw(st.integers(min_age, 95)), state_pension=draw(money(15_000)), afcs=draw(weekly_payment()), war_widows_pension=draw(weekly_payment()), @@ -92,8 +102,8 @@ def adult(draw): @st.composite -def family(draw): - adults = [draw(adult()) for _ in range(draw(st.integers(1, 2)))] +def family(draw, min_age=67): + adults = [draw(adult(min_age)) for _ in range(draw(st.integers(1, 2)))] return dict( adults=adults, # A carer in some families, by caring hours or a reported award. @@ -234,10 +244,12 @@ def test_counted_war_pension_income_bounds_and_closed_form(families): def test_guarantee_credit_does_not_rise_with_an_afcs_payment(families): # The first adult's AFCS payment runs over the grid; their war widow's # pension and the partner's payments stay as drawn. + # No private pension, so that Guarantee Credit is usually paid and the + # property is not met vacuously by a Guarantee Credit of nil throughout. rows = [ ( fam, - fam["private_pension"], + 0.0, [(weekly * WEEKS, fam["adults"][0]["war_widows_pension"])] + drawn_payments(fam)[1:], ) @@ -263,3 +275,42 @@ def test_reassigning_payments_between_sources_and_partners(families): original, after = get(variable), get_moved(variable) for i, fam in enumerate(families): assert np.allclose(original[i], after[i], atol=0.01), (fam, variable) + + +def no_payments(fam): + return [(0.0, 0.0) for _ in fam["adults"]] + + +# Aged 77 or over, so that Savings Credit, and with it the savings-credit-only +# route, is often due. +@PROPERTY_SETTINGS +@given(st.lists(family(min_age=77), min_size=1, max_size=4)) +def test_housing_benefit_and_ctr_disregard_war_pensions_on_every_route(families): + with_payments = pension_grid(families) + without = pension_grid(families, payments=no_payments) + variables = [ + "guarantee_credit", + "in_receipt_of_savings_credit_only", + "pension_credit", + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + ] + a = {v: with_payments(v) for v in variables} + b = {v: without(v) for v in variables} + same_route = ( + (a["guarantee_credit"] <= 0) + & (b["guarantee_credit"] <= 0) + & ( + a["in_receipt_of_savings_credit_only"].astype(bool) + == b["in_receipt_of_savings_credit_only"].astype(bool) + ) + ) + paid_change = a["pension_credit"] - b["pension_credit"] + for income in [ + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + ]: + compared = same_route & (a[income] > 0) & (b[income] > 0) + assert np.allclose( + (a[income] - b[income])[compared], paid_change[compared], atol=0.01 + ), (families, income) 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..a5972f1cfe 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 @@ -33,11 +33,13 @@ class housing_benefit_savings_credit_only_income(Variable): "maintenance disregards (neither income definition counts " "maintenance), the exempt work disregard, the income and capital of a " "partner treated as a household member but not in the Pension Credit " - "assessment, income of a non-dependant treated as the claimant's, and " - "war pensions (Great Britain: local schemes under section 134(8) of " + "assessment, and income of a non-dependant treated as the claimant's. " + "War pensions (Great Britain: local schemes under section 134(8) of " "the Social Security Administration Act 1992; Northern Ireland: war " - "pension income above £10 disregarded under Schedule 6 paragraph 1); " - "the model has no war pension income." + "pension income above £10 disregarded under Schedule 6 paragraph 1) " + "are taken as wholly disregarded, as in the general route: the war " + "pension income the Secretary of State counts " + "(pension_credit_war_pension_income) is removed from the assessment." ) definition_period = YEAR unit = GBP @@ -51,8 +53,14 @@ class housing_benefit_savings_credit_only_income(Variable): def formula(benunit, period, parameters): # SI 2006/214 reg 27(1) and (3) (NI: SR 2006/406 reg 25): the - # Secretary of State's assessment of net income for the award. - pension_credit_income = benunit("pension_credit_income", period) + # Secretary of State's assessment of net income for the award, less + # the war pension income it counts: the model takes the authority to + # disregard war pensions in full under a section 134(8) local scheme, + # as the general route does (HB/CTB Guidance Manual P2.576 lets it + # modify the Pension Service's figure for that). + pension_credit_income = benunit("pension_credit_income", period) - benunit( + "pension_credit_war_pension_income", period + ) # Reg 27(4)(a): the savings credit payable. A savings-credit-only # award pays only the savings credit, so this is the Pension Credit # paid (under the Pension Credit freeze, the frozen amount). diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py index 7a3cc2d79c..f12236954b 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py @@ -9,16 +9,22 @@ class pension_credit_war_pension_income(Variable): unit = GBP documentation = ( "The claimant's and partner's war disablement pensions, war widow's or " - "widower's pensions and Armed Forces Compensation Scheme payments, each " - "payment less the Sch. IV para. 1 disregard. afcs holds Armed Forces " - "Compensation Scheme payments, including guaranteed income payments, " - "and war disablement pensions; war_widows_pension holds war widow's or " - "widower's pensions. Each of the two gets its own weekly disregard, " - "which does not pass to the other payment or to the other member of a " - "couple. Dependants' and other benefit-unit members' payments do not " - "count (State Pension Credit Act 2002 s.5; reg. 14). Components that " - "Sch. IV paras 2 to 6 and 12 disregard in full, and reg. 15(5)(a) and " - "(ab) payments that count in full, are not separated in the data." + "widower's pensions and Armed Forces Compensation Scheme guaranteed " + "income payments, each payment less the Sch. IV para. 1 disregard " + "(£10 of each payment, the reading PolicyEngine applies). afcs holds " + "Armed Forces Compensation Scheme payments, including guaranteed " + "income payments, and war disablement pensions together, so a person " + "with both gets one £10 on the sum, not £10 on each. war_widows_pension " + "holds war widow's or widower's pensions. Each of the two variables " + "gets its own weekly disregard, which does not pass to the other or to " + "the other member of a couple. Dependants' and other benefit-unit " + "members' payments do not count (State Pension Credit Act 2002 s.5; " + "reg. 14). Not modelled: Sch. IV para. 1(cc), under which the unused " + "part of the £10 on a guaranteed income payment abated below £10 by an " + "Armed Forces Pension Scheme pension comes off that pension; the " + "components that Sch. IV paras 2 to 6 and 12 disregard in full; and " + "reg. 15(5)(a) and (ab) payments that count in full. The data cannot " + "separate any of them." ) reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/15", diff --git a/policyengine_uk/variables/gov/dwp/war_widows_pension.py b/policyengine_uk/variables/gov/dwp/war_widows_pension.py index 8bd972472a..e6987680b1 100644 --- a/policyengine_uk/variables/gov/dwp/war_widows_pension.py +++ b/policyengine_uk/variables/gov/dwp/war_widows_pension.py @@ -9,7 +9,10 @@ class war_widows_pension(Variable): "A war widow's or widower's pension as defined in State Pension Credit " "Act 2002 s.17(1): a widow's, widower's or surviving civil partner's " "pension or allowance granted in respect of a death due to service or " - "war injury. This is the gross payment. Amounts wholly disregarded by " + "war injury and payable under an enactment named in the definition of " + "war disablement pension, or granted under a scheme in s.641(1)(e) or " + "(f) of the Income Tax (Earnings and Pensions) Act 2003. This is the " + "gross payment. Amounts wholly disregarded by " "State Pension Credit Regulations 2002 Sch. IV paras 2 to 6 and 12 " "(for example a supplementary pension under article 23(2) of the " "Service Pensions Order 2006) are not separated. Family Resources " 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 55575e1987..fc3536940c 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 @@ -141,8 +141,13 @@ def formula(benunit, period, parameters): has_savings_credit_only_award = benunit( "in_receipt_of_savings_credit_only", period ) - savings_credit_only_income = benunit("pension_credit_income", period) + benunit( - "pension_credit", period + # The general route counts no war pension (as Housing Benefit, a full + # disregard), so the war pension income in the Secretary of State's + # assessment is taken out here too. + savings_credit_only_income = ( + benunit("pension_credit_income", period) + - benunit("pension_credit_war_pension_income", period) + + benunit("pension_credit", period) ) return select( [in_receipt_of_guarantee_credit, has_savings_credit_only_award], From 4422b97b90989f83cc9e375af167aaf41378d045 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:23:29 -0400 Subject: [PATCH 142/224] Review fixes: real HB disregard in tests, reg 15(1)(c)-(e) wording, removed-parameter registry From the independent review of #2507: - The Housing Benefit earnings disregard is capped at net earnings, so it is nil without earnings. Cases 16 and 17 no longer override it, and the differential property compares Housing Benefit income with Pension Credit income directly instead of adding the disregard back. - Case 9 runs on the model's 2025 minimum guarantee (227.10), which is already the statutory amount; drop the override and its stale comment. - Reg 15(1)(c) is the IIDB increases (SSCBA ss.104-105); (d) and (e) are payments under the Sch 8 schemes for old industrial injury cases. Fix the wording in the income list, the HB comment, the YAML header and the property docstring, and note that incapacity benefit's non-partner dependant increases (reg 15(1)(h)) are not separated either. - Register the removed savings_credit_excluded_sources in REMOVED_PARAMETERS, so a reform naming it fails with directions. - New cases: savings-credit-only Housing Benefit with IIDB (Case 20) and working-age Housing Benefit with IIDB (Case 21). - Correct invariant 3's description of how the model dates State Pension age. Co-Authored-By: Claude Opus 5.5 --- .../guarantee_credit/income.yaml | 8 +- ...ion_credit_prescribed_benefits_income.yaml | 82 +++++++++++++++---- ...n_credit_prescribed_benefits_properties.py | 29 ++++--- policyengine_uk/utils/parameters.py | 7 ++ .../housing_benefit_applicable_income.py | 5 +- 5 files changed, 97 insertions(+), 34 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index ec4fa71901..2f570e3c66 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -26,9 +26,11 @@ values: # Supplement, which is a separate variable. - carers_allowance - carer_support_payment - # Reg. 15(1) does not list these either. Reg. 15(1)(c) and (e) except the - # constant attendance and exceptionally severe disablement increases of - # IIDB, but iidb is the whole reported payment and cannot separate them. + # Reg. 15(1) does not list these either. Reg. 15(1)(c) excepts the constant + # attendance and exceptionally severe disablement increases of IIDB (SSCBA + # ss.104 and 105), but iidb is the whole reported payment and cannot + # separate them. Incapacity benefit's increases for a dependant other than + # a partner (reg. 15(1)(h)) are not separated either. - iidb - sda - incapacity_benefit diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml index 26babd2558..1287e36891 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml @@ -5,8 +5,9 @@ # SPC Regs 2002 reg 15(1): "all social security benefits are prescribed except" # a closed list. The list does not name the four benefits. For IIDB it excepts # the increases under SSCBA 1992 ss.104 and 105 (constant attendance and -# exceptionally severe disablement) and Sch 8 paras 4 and 7(2)(b) (reg -# 15(1)(c)-(e)); (h) excepts increases for a dependant other than a partner. +# exceptionally severe disablement; reg 15(1)(c)); (d) and (e) except payments +# under the Sch 8 schemes for old industrial injury cases, and (h) increases +# for a dependant other than a partner. # Reg 9 makes WTC, IB, SDA and MA (with contribution-based JSA, contributory # ESA and reg 15(5)(d) maintenance) non-qualifying income for Savings Credit; # IIDB stays qualifying income. HB (SPC) Regs 2006 reg 29(1)(j) counts the same @@ -243,10 +244,6 @@ benunits: benunit: members: [person1] - # SI 2025/295 art 29 sets the single standard minimum guarantee at - # 227.10; the model's 2025 parameter holds 221.85, so the statutory - # amount is supplied to isolate the income rule. - minimum_guarantee: 227.10 * 52 households: household: members: [person1] @@ -254,6 +251,7 @@ output: # 1,000 + 2,343.12 = 3,343.12. pension_credit_income: 1_000 + 45.06 * 52 + # SI 2025/295 art 29: single standard minimum guarantee 227.10. # 11,809.20 - 3,343.12 = 8,466.08. guarantee_credit: 227.10 * 52 - (1_000 + 45.06 * 52) @@ -434,10 +432,6 @@ benunits: benunit: members: [person1] - # The model applies the HB earnings disregard (HB (SPC) Regs Sch 4) - # whether or not there are earnings; there are none here, so it is - # set to zero to isolate the income rule. - housing_benefit_applicable_income_disregard: 0 households: household: members: [person1] @@ -448,7 +442,8 @@ # 1,000 + 12,162.80 = 13,162.80, above the 12,376 minimum guarantee. pension_credit_income: 1_000 + 233.90 * 52 guarantee_credit: 0 - # HB (SPC) Regs reg 29(1)(j): the same 13,162.80. + # HB (SPC) Regs reg 29(1)(j): the same 13,162.80. There are no earnings, + # so the earnings disregard (capped at net earnings) is nil. housing_benefit_applicable_income: 1_000 + 233.90 * 52 - name: Case 17, working-age Housing Benefit counts incapacity benefit and SDA. @@ -465,8 +460,6 @@ benunits: benunit: members: [person1, person2] - # As in Case 16 (the working-age disregard is HB Regs 2006 Sch 4). - housing_benefit_applicable_income_disregard: 0 # A legacy Housing Benefit claim: a Universal Credit claim would # passport Housing Benefit (housing_benefit_on_passporting_benefit) # and make its income nil, and this case tests the means test itself. @@ -479,7 +472,8 @@ rent: 6_000 output: income_tax: [0, 0] - # HB Regs 2006 reg 40(1): 7,623.20 + 6,206.20 = 13,829.40. + # HB Regs 2006 reg 40(1): 7,623.20 + 6,206.20 = 13,829.40; no earnings, + # so no earnings disregard. housing_benefit_applicable_income: (146.60 + 103.85 + 15.50) * 52 - name: Case 18, working tax credit does not add to a positive Savings Credit. @@ -543,3 +537,63 @@ savings_credit: 0 # 18,889 - 18,206.20 = 682.80. guarantee_credit: 363.25 * 52 - (12_000 + (103.85 + 15.50) * 52) + +- name: Case 20, savings-credit-only Housing Benefit counts IIDB through the Pension Credit assessment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 200 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # 10,400 + 2,432.56 = 12,832.56, above the 12,376 minimum guarantee; no + # income tax (below the personal allowance; IIDB is not taxable). + pension_credit_income: 200 * 52 + 46.78 * 52 + guarantee_credit: 0 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (246.78 - 208.07)) * 52 + # = 0.6 * 29.93 * 52 = 933.816; amount B = 0.4 * (12,832.56 - + # 12,376) = 182.624; savings credit 751.192, so the award is + # savings credit only. + savings_credit: 751.192 + # HB (SPC) Regs 2006 reg 27: the Secretary of State's assessment plus the + # savings credit payable, no earnings disregard (no earnings): + # 12,832.56 + 751.192 = 13,583.752. On main IIDB was not Pension Credit + # income, Guarantee Credit was paid and Housing Benefit income was nil. + housing_benefit_applicable_income: 13_583.752 + +- name: Case 21, working-age Housing Benefit counts IIDB. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1] + # A legacy Housing Benefit claim (see Case 17). + claims_universal_credit: false + households: + household: + members: [person1] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + income_tax: 0 + # HB Regs 2006 reg 40(1): IIDB at the 100% rate, 233.90 * 52 = 12,162.80. + housing_benefit_applicable_income: 233.90 * 52 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 defdbfb181..67448845bd 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -5,7 +5,9 @@ industrial injuries disablement benefit (IIDB), severe disablement allowance (SDA), incapacity benefit (IB) or maternity allowance (MA). For IIDB it excepts the constant attendance and exceptionally severe disablement -increases (reg 15(1)(c)-(e)), which the reported IIDB amount cannot separate. +increases (reg 15(1)(c)), which the reported IIDB amount cannot separate; +(d) and (e) except payments under the Sch 8 schemes for old industrial +injury cases. Pension-age Housing Benefit counts the same benefits under HB (SPC) Regs 2006 reg 29(1)(j), whose exceptions for these benefits match reg 15(1)'s. Reg 9 makes working tax credit, IB, SDA, MA, contribution-based JSA and contributory @@ -26,12 +28,11 @@ 1. Differential: Pension Credit and pension-age Housing Benefit assess the same income. Where neither Guarantee Credit nor Savings Credit is due and Housing Benefit income is above its zero floor, Housing Benefit applicable - income plus its income disregard equals Pension Credit income. (With a - savings-credit-only award, Housing Benefit takes the Secretary of State's - assessment plus the savings credit, HB (SPC) Regs 2006 reg 27, by design.) The disregard is added back because - the model applies the HB earnings disregard (HB (SPC) Regs Sch 4) whether or - not there are earnings; there are none here. There is no other intended - difference on this domain: both measures deduct the claimant's and + income equals Pension Credit income. (With a savings-credit-only award, + Housing Benefit takes the Secretary of State's assessment plus the savings + credit, HB (SPC) Regs 2006 reg 27, by design.) There are no earnings, so + the Housing Benefit earnings disregard, which is capped at net earnings, is + nil. There is no other intended difference on this domain: both measures deduct the claimant's and partner's income tax and National Insurance, and neither counts the Scottish Carer Supplement (SPC Regs reg 15(1)(ri); HB (SPC) Regs reg 29(1)(j)(xviiha)). @@ -45,10 +46,10 @@ of a reg 9 source (working tax credit, IB, SDA, MA, contribution-based JSA or contributory ESA) changes Savings Credit income only through income tax and National Insurance and through any Carer's Allowance or Carer Support - Payment it displaces (as in invariant 2), and never raises Savings Credit. Draws for this - invariant are aged 77 or over: the model dates State Pension age as birth - year plus the current State Pension age (66 in 2026), so they meet its - test of reaching it before the 2016 Savings Credit cutoff. + Payment it displaces (as in invariant 2), and never raises Savings Credit. + Draws for this invariant are aged 77 or over, old enough to have reached + State Pension age, on the model's timetable by date of birth, before the + April 2016 Savings Credit cutoff. 4. Boundary: on the private-pension step where Guarantee Credit ends, 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 @@ -201,7 +202,6 @@ def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( "pension_credit_income", "housing_benefit_eligible", "housing_benefit_applicable_income", - "housing_benefit_applicable_income_disregard", ], ) hb_income = g["housing_benefit_applicable_income"] @@ -213,11 +213,10 @@ def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( & g["housing_benefit_eligible"].astype(bool) & (hb_income > 0) ) - before_disregard = hb_income + g["housing_benefit_applicable_income_disregard"] pc_income = g["pension_credit_income"] - assert np.allclose(before_disregard[compared], pc_income[compared], atol=0.01), ( + assert np.allclose(hb_income[compared], pc_income[compared], atol=0.01), ( families, - before_disregard[compared], + hb_income[compared], pc_income[compared], ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index e7d1be6aed..66f93aa66a 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -179,6 +179,13 @@ class RemovedParameter(NamedTuple): "gov.simulation.labour_supply_responses.obr_elasticities": RemovedParameter( "removed OBR elasticities switch", _OBR_ELASTICITIES_REPLACEMENT ), + "gov.dwp.pension_credit.income.savings_credit_excluded_sources": RemovedParameter( + "removed duplicate Savings Credit excluded-income list", + "The model never read it. Change " + "gov.dwp.pension_credit.savings_credit.excluded_income, the list of " + "income that is not qualifying income for Savings Credit (SPC Regs " + "2002 reg 9), instead.", + ), } diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 17e6697cb1..f376690d42 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -69,8 +69,9 @@ def formula(benunit, period, parameters): "ssmg", # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), whose # exceptions touching these are the IIDB increases under SSCBA - # ss.104-105 and Sch 8 (heads (iii)-(v)) and non-partner dependant - # increases (head (viii)), and HB Regs 2006 reg 40 with Sch 5, + # ss.104-105 (head (iii)) and non-partner dependant increases (head + # (viii)); heads (iv) and (v) cover the Sch 8 schemes for old + # industrial injury cases. And HB Regs 2006 reg 40 with Sch 5, # whose para 9 attendance allowance disregard covers the # ss.104-105 increases (reg 2(1)). iidb is the whole reported # payment and cannot separate those increases. From a1eef99239109c8f9b89abaa1b650ea7644594ac Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 22:34:19 -0400 Subject: [PATCH 143/224] Floor the savings-credit-only war pension removal; avoid reg 7(4) in Case 14 From the delta review of #2508: - Pension Credit income is net of tax on income it does not count (for example dividends), so it can be smaller than the war pension income it includes. Floor the difference at nil before adding the savings credit payable, in both the Housing Benefit and the Council Tax Reduction savings-credit-only routes. - Case 14 now uses inputs whose amount B is whole pennies (private pension 240 a week, no tax), so the model's unrounded savings credit (476.216) is the regulations' figure; the model does not apply reg 7(4) rounding. - Para 1(cc) covers any pension or payment within AFCS Order 2005 art 31(1)(a) or (b), not only an Armed Forces Pension Scheme pension. Co-Authored-By: Claude Opus 5.5 --- .../pension_credit_war_pension_income.yaml | 34 ++++++++++--------- ...sing_benefit_savings_credit_only_income.py | 6 ++-- .../pension_credit_war_pension_income.py | 7 ++-- ...council_tax_reduction_applicable_income.py | 8 ++--- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml index b8d49ad37f..c81788d84c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -363,7 +363,7 @@ person1: age: 80 state_pension: 0 - private_pension_income: 250 * 52 + private_pension_income: 240 * 52 afcs: 30 * 52 benunits: benunit: @@ -377,23 +377,25 @@ rent: 6_000 council_tax: 1_500 output: - # Income tax on the private pension: (13,000 - 12,570) * 0.2 = 86; the AFCS - # payment is not taxable income. - income_tax: 86 + # The private pension (12,480) is below the 12,570 personal allowance and + # the AFCS payment is not taxable income, so there is no income tax. + income_tax: 0 pension_credit_war_pension_income: (30 - 10) * 52 - # 13,000 + 1,040 - 86 = 13,954, above the 12,376 minimum guarantee. - pension_credit_income: 13_954 + # 12,480 + 1,040 = 13,520 (260.00 a week), above the 12,376 minimum + # guarantee. + pension_credit_income: 13_520 guarantee_credit: 0 - # Amount A = min(0.6 * (238 - 208.07), 0.6 * (13,954 / 52 - 208.07)) * 52 - # = 0.6 * 29.93 * 52 = 933.816; amount B = 0.4 * (13,954 - 12,376) - # = 631.20; savings credit = 302.616, so the award is savings - # credit only. - savings_credit: 302.616 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (260 - 208.07)) = 17.958 a + # week; amount B = 0.4 * (260 - 238) = 8.80 a week, whole pennies so reg + # 7(4) rounding does not arise; savings credit (17.958 - 8.80) * 52 = + # 476.216, so the award is savings credit only. + savings_credit: 476.216 # HB (SPC) Regs 2006 reg 27: the Secretary of State's assessment plus the # savings credit payable. The model takes the authority to disregard war # pensions in full (SSAA 1992 s.134(8); HB/CTB Guidance Manual P2.576), so - # the 1,040 counted for Pension Credit comes out: 13,954 - 1,040 + 302.616 - # = 13,216.616. There are no earnings, so no earnings disregard. - housing_benefit_applicable_income: 13_216.616 - # The CTR pensioner scheme's savings-credit-only route, the same way. - council_tax_reduction_applicable_income: 13_216.616 + # the 1,040 counted for Pension Credit comes out: 13,520 - 1,040 + 476.216 + # = 12,956.216. There are no earnings, so no earnings disregard. + housing_benefit_applicable_income: 12_956.216 + # The CTR savings-credit-only route: its general route counts no war + # pension, so the same 1,040 comes out. + council_tax_reduction_applicable_income: 12_956.216 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 a5972f1cfe..684aa7a543 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 @@ -58,8 +58,10 @@ def formula(benunit, period, parameters): # disregard war pensions in full under a section 134(8) local scheme, # as the general route does (HB/CTB Guidance Manual P2.576 lets it # modify the Pension Service's figure for that). - pension_credit_income = benunit("pension_credit_income", period) - benunit( - "pension_credit_war_pension_income", period + pension_credit_income = max_( + 0, + benunit("pension_credit_income", period) + - benunit("pension_credit_war_pension_income", period), ) # Reg 27(4)(a): the savings credit payable. A savings-credit-only # award pays only the savings credit, so this is the Pension Credit diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py index f12236954b..1abb1d31ec 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py @@ -20,8 +20,11 @@ class pension_credit_war_pension_income(Variable): "the other member of a couple. Dependants' and other benefit-unit " "members' payments do not count (State Pension Credit Act 2002 s.5; " "reg. 14). Not modelled: Sch. IV para. 1(cc), under which the unused " - "part of the £10 on a guaranteed income payment abated below £10 by an " - "Armed Forces Pension Scheme pension comes off that pension; the " + "part of the £10 on a guaranteed income payment abated below £10 by a " + "pension or payment within article 31(1)(a) or (b) of the Armed Forces " + "and Reserve Forces (Compensation Scheme) Order 2005 (an Armed Forces " + "Pension Scheme pension or Early Departure Scheme payment) comes off " + "that pension or payment; the " "components that Sch. IV paras 2 to 6 and 12 disregard in full; and " "reg. 15(5)(a) and (ab) payments that count in full. The data cannot " "separate any of them." 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 fc3536940c..eb554c6373 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 @@ -144,11 +144,11 @@ def formula(benunit, period, parameters): # The general route counts no war pension (as Housing Benefit, a full # disregard), so the war pension income in the Secretary of State's # assessment is taken out here too. - savings_credit_only_income = ( + savings_credit_only_income = max_( + 0, benunit("pension_credit_income", period) - - benunit("pension_credit_war_pension_income", period) - + benunit("pension_credit", period) - ) + - benunit("pension_credit_war_pension_income", period), + ) + benunit("pension_credit", period) return select( [in_receipt_of_guarantee_credit, has_savings_credit_only_award], [0, savings_credit_only_income], From 815f89cb7a5799cc7dac8036b2f984f4e0125890 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:12:38 +0000 Subject: [PATCH 144/224] Update package version --- CHANGELOG.md | 14 ++++++++++++++ changelog.d/1970.fixed.md | 1 - changelog.d/2042.fixed.md | 1 - changelog.d/2050.fixed.md | 1 - changelog.d/2111.fixed.md | 1 - changelog.d/2168.changed.md | 1 - pyproject.toml | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) delete mode 100644 changelog.d/1970.fixed.md delete mode 100644 changelog.d/2042.fixed.md delete mode 100644 changelog.d/2050.fixed.md delete mode 100644 changelog.d/2111.fixed.md delete mode 100644 changelog.d/2168.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a08c0cef83..08b898db39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [2.139.5] - 2026-10-20 + +### Changed + +- - Documented employer NI incidence assumptions, input handling, revenue measures and current household examples. + +### Fixed + +- - Separated Universal Credit's higher disabled child condition from tax credits, included certified blindness, and prevented the lower and higher additions from stacking. +- - Corrected Working Tax Credit eligibility so age, disability and hours conditions applied to the same claimant or partner, while preserving the parental combined-hours and partner-exception routes. +- - Restricted the Child Tax Credit family element to families responsible for a qualifying child or young person born before 6 April 2017, while preserving earlier supported years. +- - Excluded Child Benefit from Great Britain's pensioner Council Tax Reduction general-rule income, preserving working-age and statutory-pay treatment. + + ## [2.139.4] - 2026-10-19 ### Fixed diff --git a/changelog.d/1970.fixed.md b/changelog.d/1970.fixed.md deleted file mode 100644 index eadc51f308..0000000000 --- a/changelog.d/1970.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Separated Universal Credit's higher disabled child condition from tax credits, included certified blindness, and prevented the lower and higher additions from stacking. diff --git a/changelog.d/2042.fixed.md b/changelog.d/2042.fixed.md deleted file mode 100644 index d99e12b288..0000000000 --- a/changelog.d/2042.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Corrected Working Tax Credit eligibility so age, disability and hours conditions applied to the same claimant or partner, while preserving the parental combined-hours and partner-exception routes. diff --git a/changelog.d/2050.fixed.md b/changelog.d/2050.fixed.md deleted file mode 100644 index 4b1f68f8ac..0000000000 --- a/changelog.d/2050.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Restricted the Child Tax Credit family element to families responsible for a qualifying child or young person born before 6 April 2017, while preserving earlier supported years. diff --git a/changelog.d/2111.fixed.md b/changelog.d/2111.fixed.md deleted file mode 100644 index b696059d2b..0000000000 --- a/changelog.d/2111.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Excluded Child Benefit from Great Britain's pensioner Council Tax Reduction general-rule income, preserving working-age and statutory-pay treatment. diff --git a/changelog.d/2168.changed.md b/changelog.d/2168.changed.md deleted file mode 100644 index 1d6027d123..0000000000 --- a/changelog.d/2168.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Documented employer NI incidence assumptions, input handling, revenue measures and current household examples. diff --git a/pyproject.toml b/pyproject.toml index 9098d0b074..d0512696a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.139.4" +version = "2.139.5" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 42b11b41681a933da757215c6a89b08455fdbe29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 23:15:40 -0400 Subject: [PATCH 145/224] Keep main's new England CTR Child Benefit tests on the higher rate Two cases in council_tax_reduction_pensioner_child_benefit.yaml assume the higher pension-age allowance (256 x 52) for a pensioner aged 70, who reached State Pension age after 1 April 2021. They now use a pensioner aged 72 (State Pension age reached in 2019), as for main's other such tests. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_pensioner_child_benefit.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml index 0eaf16432d..dedf76043f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml @@ -3,11 +3,13 @@ # Model2026: eldest Child Benefit27.05*52=1406.60; aged allowance256*52=13312. # These genuine claim/takeup inputs use the general rules, not PC overrides. - name: England pensioner Child Benefit is excluded before the CTR taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: - parent: {age: 70, is_parent: true, state_pension: 16000} + parent: {age: 72, is_parent: true, state_pension: 16000} child: {age: 9} benunits: family: @@ -83,11 +85,13 @@ council_tax_reduction: 3000 - name: Not claiming Child Benefit preserves the pensioner general-rule income + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: - parent: {age: 70, is_parent: true, state_pension: 16000} + parent: {age: 72, is_parent: true, state_pension: 16000} child: {age: 9} benunits: family: From d77037fc08bfe97337a095e4371e2320af6a6552 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 23:19:27 -0400 Subject: [PATCH 146/224] Address review round 1: reproducer, route coverage, UC caveats - Make the HB-reform disregard-cap reproducer read only variables that exist on main, so it fails there by value (disregard 250, not 200); the new variables move to a companion case. - Add a Hypothesis example with a contributing family on all 13 routes, so every route is tested whatever Hypothesis draws. - Extend the rate selector's UC caveat to Oxford, whose Appendix 3 assesses UC claimants on DWP's net earned income (#2520), and cite UC Regs 2013 reg 55(5)(a) for the full deduction. - Say that SSI 2021/249 reg 51(9) takes self-employed net profit from SSI 2012/303 regs 37 and 38, and that Oxford's share is for applicants who do not receive UC. - Describe HB net earnings as deducting HB's own share. Co-Authored-By: Claude Opus 5.5 --- .../pension_contribution_deduction_rate.yaml | 4 ++- .../pension_contribution_deduction_rate.yaml | 2 +- .../pension_contribution_deduction_rate.yaml | 31 ++++++++++++++++-- ..._contribution_deduction_rate_properties.py | 32 +++++++++++++++++-- .../housing_benefit_net_earnings.py | 5 +-- ...ion_pension_contribution_deduction_rate.py | 26 +++++++++------ 6 files changed, 81 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml index 2856e8dc9c..ebac88d7f5 100644 --- a/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -1,4 +1,4 @@ -description: The Oxford Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +description: The Oxford Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant who does not receive Universal Credit. An applicant who does is assessed on the net earned income DWP provides (Appendix 3), which the model does not yet do (issue 2520). values: 2026-04-01: 0.5 metadata: @@ -11,3 +11,5 @@ metadata: href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27 - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Annex 5 (one half of personal pension scheme payments, self-employed earners) href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27/3 + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Appendix 3 (applicants who receive Universal Credit) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27/4 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml index 0345f58a56..b3681ebec4 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -13,7 +13,7 @@ metadata: href: https://www.legislation.gov.uk/ssi/2012/319/regulation/36 - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 50(3)(b) and (c) and (5)(c) (employed earners under pension age with no award of Universal Credit, from 1 April 2022) href: https://www.legislation.gov.uk/ssi/2021/249/regulation/50 - - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 51(9) (self-employed earners under pension age with no award of Universal Credit, by SSI 2012/303 reg. 37) + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 51(9) (self-employed earners under pension age with no award of Universal Credit, whose net profit reg. 51(9) takes from SSI 2012/303 regs. 37 and 38) href: https://www.legislation.gov.uk/ssi/2021/249/regulation/51 - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 35(3)(b) and (c) and (6)(c) and reg. 37(1)(b)(ii), (3)(c) and (9)(b) (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml index 9f34719d19..38fc3f1288 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml @@ -698,7 +698,8 @@ # 300 - 0.25 x 200 = 250, but the Council Tax Reduction cap stays at its own # 300 - 0.5 x 200 = 200, so the disregard stays 200 and applicable income # 5,000. Reading Housing Benefit's net earnings, the disregard was 250 and - # applicable income 5,300 - 0.5 x 200 - 250 = 4,950. + # applicable income 5,300 - 0.5 x 200 - 250 = 4,950. The outputs use only + # variables that exist before this change, so the case fails there by value. period: 2026 absolute_error_margin: 0.01 input: @@ -719,12 +720,36 @@ members: [pensioner] country: ENGLAND output: - council_tax_reduction_pension_contribution_deduction_rate: 0.5 housing_benefit_net_earnings: 250 - council_tax_reduction_net_earnings: 200 council_tax_reduction_pensioner_earnings_disregard: 200 council_tax_reduction_applicable_income: 5_000 +- name: Under a reform of the Housing Benefit share Council Tax Reduction net earnings keep the scheme's share + # Model reform, not current law. The same pensioner: the England pensioner + # scheme's share stays 0.5, so its net earnings stay 300 - 0.5 x 200 = 200. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: 0.5 + council_tax_reduction_net_earnings: 200 + - name: A couple's contributions are summed at the scheme's share # Scotland, working age. Each partner earns 10,000 and they contribute 2,000 # and 1,000. Applicable income is 20,000 - 0.5 x 3,000 = 18,500. diff --git a/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py index 51a613efbd..27b066f7db 100644 --- a/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py +++ b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py @@ -27,7 +27,8 @@ scheme's share (or Housing Benefit's) would miss invariant 1. Pensioners in Merton follow the England pensioner scheme, not Merton's, and families in Northern Ireland, which has no Council Tax Reduction, read the England - default scheme's share. + default scheme's share. An explicit example puts a contributing family on + every route, so each is tested whatever else Hypothesis draws. 3. The pensioner earnings disregards do not move when the disregard caps are slack. 4. Differential: at current law Council Tax Reduction net earnings equal @@ -38,7 +39,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -184,12 +185,39 @@ def contributions(unit): return sum(adult["personal_pension_contributions"] for adult in unit["adults"]) +def every_route(): + """One contributing family on each route, a couple on one of them, so that + every route is tested whatever Hypothesis draws.""" + units = [] + for route in sorted(ROUTES): + age = 70 if ROUTES[route][2] else 40 + adult = dict( + age=age, employment_income=20_000.0, personal_pension_contributions=2_000.0 + ) + units.append(dict(route=route, adults=[adult])) + # A partner two years older, in the same age band as the head. + partner_age = units[0]["adults"][0]["age"] + 2 + units[0]["adults"].append( + dict( + age=partner_age, + employment_income=30_000.0, + personal_pension_contributions=1_500.0, + ) + ) + return units + + @PROPERTY_SETTINGS @given( st.lists(families(), min_size=1, max_size=26), reforms(), st.sampled_from([2026, 2028, 2030]), ) +@example( + units=every_route(), + shares=dict(zip(PARAMETERS, SHARES)), + year=2026, +) def test_each_scheme_moves_by_contributions_times_its_own_change_in_share( units, shares, year ): diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py index c7b939c8f9..d060fcf3c0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py @@ -15,8 +15,9 @@ class housing_benefit_net_earnings(Variable): "in one employment is not set against another) plus statutory sick " "and maternity pay (regulation 35(1)(i); pension age regulation " "35(1)(h)), less income tax, " - "Class 1, 2 and 4 National Insurance, and half of pension " - "contributions, floored at zero. For an employee the regulations " + "Class 1, 2 and 4 National Insurance, and Housing Benefit's share of " + "pension contributions (pension_contribution_deduction_rate, one-half " + "in law), floored at zero. For an employee the regulations " "deduct the income tax deducted from the earnings (regulation 36(3); " "pension age regulation 36(2)); for the self-employed, a notional " "basic-rate tax on the profit alone less personal reliefs (regulation " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py index 6b9b5372a6..883a78c9c8 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py @@ -18,23 +18,28 @@ class council_tax_reduction_pension_contribution_deduction_rate(Variable): documentation = ( "The share of pension contributions deducted from earnings in the " "Council Tax Reduction means test, under the family's own scheme. " - "Each scheme states its own share, one-half in every scheme modelled: " + "Each scheme sets its own share, one-half in every scheme modelled: " "the England pensioner scheme (SI 2012/2885 Sch 1 paras 19 and 29), " "Wales (WSI 2013/3029 Sch 1 paras 13 and 23 and Sch 6 paras 15 and " - "24), Scotland (SSI 2012/319 regs 33 and 36; SSI 2021/249 regs 50 and " - "51(9)) and the working-age schemes of Merton, Kingston upon Thames, " + "24), Scotland (SSI 2012/319 regs 33 and 36; SSI 2021/249 reg 50, and " + "for the self-employed reg 51(9), which applies SSI 2012/303 regs 37 " + "and 38) and the working-age schemes of Merton, Kingston upon Thames, " "Newham, Westminster and Oxford. None of them refers to Housing " "Benefit's share, so a reform of one scheme's share moves only that " "scheme. English working-age families outside those councils, whose " "reductions the model takes as reported, and families outside Great " "Britain, where there is no Council Tax Reduction, read the England " - "default scheme's share (SI 2012/2886 Sch paras 42, 52 and 61). In " - "Wales and Scotland an applicant under pension age with an award of " - "Universal Credit is assessed on the Universal Credit figures, which " - "deduct relievable pension contributions in full (WSI 2013/3029 Sch " - "6 para 9; SSI 2021/249 regs 49(6)(a) and 51(2)); the national " - "schemes' Universal Credit route is not modelled, so the share here " - "applies to them too." + "default scheme's share (SI 2012/2886 Sch paras 42, 52 and 61). " + "A working-age applicant with an award of Universal Credit is " + "assessed instead on the Secretary of State's Universal Credit " + "figures, which deduct relievable pension contributions in full " + "(UC Regs 2013 reg 55(5)(a)): in Wales (WSI 2013/3029 Sch 6 para 9), " + "Scotland (SSI 2021/249 regs 49(6)(a) and 51(2)) and Oxford (its " + "scheme's Appendix 3). The model has no such route for them yet " + "(#1962 for Wales and Scotland, #2520 for Oxford), so the share here " + "reaches those applicants too. Merton, Kingston upon Thames, Newham " + "and Westminster already assess them on Universal Credit income " + "(_legacy.py)." ) definition_period = YEAR unit = "/1" @@ -46,6 +51,7 @@ class council_tax_reduction_pension_contribution_deduction_rate(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", ) def formula(benunit, period, parameters): From daa3291a8fa4b0a3090b1eef6a0420440a768779 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 23:29:24 -0400 Subject: [PATCH 147/224] Cite UC Regs reg 57(2) step 4 for self-employed UC earnings Review round 2 (finding A): reg 55(5)(a) covers employed earnings only; the full deduction of relievable pension contributions from self-employed earnings is reg 57(2) step 4. Documentation only. Co-Authored-By: Claude Opus 5.5 --- ...uncil_tax_reduction_pension_contribution_deduction_rate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py index 883a78c9c8..d18898ecfa 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py @@ -33,7 +33,8 @@ class council_tax_reduction_pension_contribution_deduction_rate(Variable): "A working-age applicant with an award of Universal Credit is " "assessed instead on the Secretary of State's Universal Credit " "figures, which deduct relievable pension contributions in full " - "(UC Regs 2013 reg 55(5)(a)): in Wales (WSI 2013/3029 Sch 6 para 9), " + "(UC Regs 2013 reg 55(5)(a) for employed earnings and reg 57(2) step 4 " + "for self-employed earnings): in Wales (WSI 2013/3029 Sch 6 para 9), " "Scotland (SSI 2021/249 regs 49(6)(a) and 51(2)) and Oxford (its " "scheme's Appendix 3). The model has no such route for them yet " "(#1962 for Wales and Scotland, #2520 for Oxford), so the share here " @@ -52,6 +53,7 @@ class council_tax_reduction_pension_contribution_deduction_rate(Variable): "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ) def formula(benunit, period, parameters): From f81447abe7f0b71f1c76580488949110646da765 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 16:25:23 -0400 Subject: [PATCH 148/224] 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 149/224] =?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 150/224] 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 151/224] 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 152/224] 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 153/224] 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 154/224] 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 155/224] 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 156/224] 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 47642d1ee00e33f98affd32d47e7a5516e5e4bd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:34:54 +0000 Subject: [PATCH 157/224] Update package version --- CHANGELOG.md | 14 ++++++++++++++ changelog.d/2424.fixed.md | 1 - changelog.d/2480.fixed.md | 1 - changelog.d/332.added.md | 1 - changelog.d/71.added.md | 1 - .../fix-2295-scottish-ctr-nondependants.fixed.md | 1 - pyproject.toml | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) delete mode 100644 changelog.d/2424.fixed.md delete mode 100644 changelog.d/2480.fixed.md delete mode 100644 changelog.d/332.added.md delete mode 100644 changelog.d/71.added.md delete mode 100644 changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b898db39..926c6a3b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [2.140.0] - 2026-10-20 + +### Added + +- Added normalized annual BHC and AHC squared poverty-gap components and documented person-weighted aggregation and undefined poverty-line handling. +- - Added a configurable Income Support withdrawal rate, preserving the existing 100% default. + +### Fixed + +- Use Scotland's working-age Council Tax Reduction young-person definition for the severe disability premium's non-dependant test within the applicant's benefit unit, retaining existing looked-after and other-benefit-unit treatment. +- Count Incapacity Benefit and Severe Disablement Allowance as Universal Credit unearned income from the GB transitional commencement, preserving earlier history and the existing annual-model limitations. +- Fix stale construction-time reform wrappers after foreign writes or direct edits while preserving copied scenarios and constructor readings; document mixed-composite replay, indexed-threshold construction and the existing rejection of built overrides whose keys contain no 1 January annual value. + + ## [2.139.5] - 2026-10-20 ### Changed diff --git a/changelog.d/2424.fixed.md b/changelog.d/2424.fixed.md deleted file mode 100644 index 3883ab7bb4..0000000000 --- a/changelog.d/2424.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Count Incapacity Benefit and Severe Disablement Allowance as Universal Credit unearned income from the GB transitional commencement, preserving earlier history and the existing annual-model limitations. diff --git a/changelog.d/2480.fixed.md b/changelog.d/2480.fixed.md deleted file mode 100644 index 6aa350961f..0000000000 --- a/changelog.d/2480.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix stale construction-time reform wrappers after foreign writes or direct edits while preserving copied scenarios and constructor readings; document mixed-composite replay, indexed-threshold construction and the existing rejection of built overrides whose keys contain no 1 January annual value. diff --git a/changelog.d/332.added.md b/changelog.d/332.added.md deleted file mode 100644 index ae1a10b2b3..0000000000 --- a/changelog.d/332.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added a configurable Income Support withdrawal rate, preserving the existing 100% default. diff --git a/changelog.d/71.added.md b/changelog.d/71.added.md deleted file mode 100644 index 9c5f66741f..0000000000 --- a/changelog.d/71.added.md +++ /dev/null @@ -1 +0,0 @@ -Added normalized annual BHC and AHC squared poverty-gap components and documented person-weighted aggregation and undefined poverty-line handling. diff --git a/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md b/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md deleted file mode 100644 index 2850e8d98a..0000000000 --- a/changelog.d/fix-2295-scottish-ctr-nondependants.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Use Scotland's working-age Council Tax Reduction young-person definition for the severe disability premium's non-dependant test within the applicant's benefit unit, retaining existing looked-after and other-benefit-unit treatment. diff --git a/pyproject.toml b/pyproject.toml index d0512696a7..782ecdc505 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.139.5" +version = "2.140.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ 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 158/224] 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 159/224] 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 160/224] 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 161/224] 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 162/224] 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 10ee9bcbbdff6b08cfc8605b855c37378a0f7f8c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 03:43:49 -0400 Subject: [PATCH 163/224] Compare a pull request's merge commit with its first parent For pull_request runs, the Aggregate impact workflow compared GitHub's merge commit with github.event.pull_request.base.sha. That SHA lags the base branch once the branch moves on, so the report counted main's newer merges as the pull request's impact (#2516's report took in 26 commits from main, #2518's 10). The head is now checked out with its parents (fetch-depth 2), and a new "Choose the base" step reads the merge commit's first parent from it, as pr_code_changes.yaml's Changes job already does. The step fails the job if the head does not have exactly two parents. A workflow_dispatch run passes its base through unchanged. The tests run the workflow's own step scripts on real git histories, with the head fetched at the workflow's fetch depth: the base is the first parent for any history (Hypothesis), a head without two parents fails, no step reads pull_request.base.sha, and a dispatch is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/aggregate-impact.yaml | 39 ++- .../aggregate-impact-merge-parent.fixed.md | 1 + .../test_aggregate_impact_workflow.py | 274 +++++++++++++++++- 3 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 changelog.d/aggregate-impact-merge-parent.fixed.md diff --git a/.github/workflows/aggregate-impact.yaml b/.github/workflows/aggregate-impact.yaml index 7740c688a2..fe842b9637 100644 --- a/.github/workflows/aggregate-impact.yaml +++ b/.github/workflows/aggregate-impact.yaml @@ -1,6 +1,8 @@ # Measure a pull request's base and head on the certified dataset and post # the comparison: receipts by tax head, spending and caseloads, poverty, # inequality, deciles and the standard reform costings, 2025-26 to 2031-32. +# For a pull request, the head is GitHub's merge commit and the base is that +# commit's first parent, so the report is exactly what merging would change. # # Both sides run the pull request head's copy of the measurement tool # (policyengine_uk/utils/aggregate_impact), each in an environment synced @@ -66,16 +68,15 @@ jobs: id: commits env: EVENT: ${{ github.event_name }} - PR_BASE: ${{ github.event.pull_request.base.sha }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} INPUT_BASE: ${{ inputs.base }} INPUT_HEAD: ${{ inputs.head }} run: | - # A pull request's head is its merge commit, whose first parent is - # the base, so the comparison is exactly what merging would change. + # A pull request's head is its merge commit. Its base is read from + # that commit once the head is checked out (Choose the base). if [ "$EVENT" = "pull_request" ]; then - base="$PR_BASE"; head="$GITHUB_SHA" + base=""; head="$GITHUB_SHA" base_name="$PR_BASE_REF"; head_name="#$PR_NUMBER merged into $PR_BASE_REF" else base="${INPUT_BASE:-main}"; head="${INPUT_HEAD:-$GITHUB_SHA}" @@ -93,11 +94,39 @@ jobs: with: ref: ${{ steps.commits.outputs.head }} path: head + # A pull request's merge commit and its parents, so that the next + # step can read the base from it. + fetch-depth: 2 + + - name: Choose the base + id: base + env: + EVENT: ${{ github.event_name }} + BASE: ${{ steps.commits.outputs.base }} + run: | + # A pull request's base is its merge commit's first parent: the tip + # of the branch it merges into, as GitHub made the merge commit for + # this run. The comparison is then exactly what merging would + # change. The event's pull_request.base.sha is not that commit: it + # lags the branch once the branch has moved on, and the report then + # counted the branch's newer merges as the pull request's impact + # (#2516's took in 26 commits from main). + if [ "$EVENT" = "pull_request" ]; then + # The merge commit, then its parents. + line=$(git -C head rev-list --parents -n 1 HEAD) + read -r -a commit <<< "$line" + if [ "${#commit[@]}" -ne 3 ]; then + echo "::error::The head ${commit[0]} has $((${#commit[@]} - 1)) parents, not 2, so it is not a pull request's merge commit and its base is unknown." + exit 1 + fi + BASE="${commit[1]}" + fi + echo "sha=$BASE" >> "$GITHUB_OUTPUT" - name: Check out the base uses: actions/checkout@v6 with: - ref: ${{ steps.commits.outputs.base }} + ref: ${{ steps.base.outputs.sha }} path: base - name: Install uv diff --git a/changelog.d/aggregate-impact-merge-parent.fixed.md b/changelog.d/aggregate-impact-merge-parent.fixed.md new file mode 100644 index 0000000000..09a3a244bd --- /dev/null +++ b/changelog.d/aggregate-impact-merge-parent.fixed.md @@ -0,0 +1 @@ +- The Aggregate impact report now compares a pull request's merge commit with that commit's first parent, the tip of the branch it merges into. It used the event's `pull_request.base.sha`, which lags that branch once it moves on, so newer merges to `main` were counted as the pull request's impact. diff --git a/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py b/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py index b9554b3e52..503f4a414e 100644 --- a/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py +++ b/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py @@ -1,17 +1,41 @@ -"""Data protection: only aggregates may leave the aggregate-impact job. +"""What the aggregate-impact job compares, and what may leave it. -The certified dataset is licensed survey microdata. The measurement keeps -household-level detail for the decile comparison under ``private/``; these -tests fail if the workflow could publish it, or could put the dataset where -other workflows can read it. +Comparison base. For a pull request the head is GitHub's merge commit and +the base is that commit's first parent, so the report is exactly what +merging would change. The event's ``pull_request.base.sha`` lags the base +branch once the branch moves on, and a report against it counted main's +newer merges as the pull request's impact (#2516's took in 26 commits from +main). These tests run the workflow's own step scripts on real histories, +with the head fetched as actions/checkout fetches it at the workflow's +fetch depth, and hold the job to: + +1. The base is the merge commit's first parent for every shape of history, + so no commit of the base branch is counted as the pull request's. +2. A head that is not a merge of two commits fails the job with an error + and sets no base. +3. No step reads ``pull_request.base.sha``, and the base checkout uses the + base the job chose. +4. A manual dispatch compares the commits it names, as before. + +Data protection. Only aggregates may leave the job. The certified dataset +is licensed survey microdata. The measurement keeps household-level detail +for the decile comparison under ``private/``; these tests fail if the +workflow could publish it, or could put the dataset where other workflows +can read it. """ import json +import os +import re +import subprocess +import tempfile from pathlib import Path import numpy as np import pytest import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st from policyengine_uk.utils.aggregate_impact.measure import ( HOUSEHOLDS_FILE, @@ -95,3 +119,243 @@ def test_a_failed_run_always_replaces_an_earlier_comment(): assert post["if"] == "always() && github.event_name == 'pull_request'" assert "if [ ! -s report/comment.md ]" in post["run"] assert "FAIL" in post["run"] + + +# The comparison base. + +GIT_ENV = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", +} +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def step_named(name): + return next(s for s in steps() if s.get("name") == name) + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env=GIT_ENV, + ).stdout.strip() + + +def run_step(name, workspace, **env): + """Run a step's script in ``workspace`` as Actions runs it. + + Each variable in the step's ``env`` is set, empty unless given, as an + expression with no value is. Returns the process and the outputs the + step wrote to ``GITHUB_OUTPUT``. + """ + step = step_named(name) + outputs = Path(workspace) / "github_output" + outputs.write_text("") + variables = {key: "" for key in step.get("env", {})} + variables.update(env) + process = subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", step["run"]], + cwd=workspace, + env={**GIT_ENV, **variables, "GITHUB_OUTPUT": str(outputs)}, + capture_output=True, + text=True, + ) + written = dict(line.split("=", 1) for line in outputs.read_text().splitlines()) + return process, written + + +class Origin: + """A repository on GitHub's side, holding commits made directly.""" + + def __init__(self, path): + path.mkdir(parents=True) + git(path, "init", "-q") + # actions/checkout fetches the head by its SHA. + git(path, "config", "uploadpack.allowAnySHA1InWant", "true") + self.path = path + self.tree = git(path, "write-tree") + self.made = 0 + + def commit(self, *parents): + self.made += 1 + flags = [flag for parent in parents for flag in ("-p", parent)] + return git( + self.path, "commit-tree", self.tree, *flags, "-m", f"commit {self.made}" + ) + + def attributed(self, base, head): + """The commits a comparison of ``base`` with ``head`` counts.""" + return set(git(self.path, "rev-list", f"{base}..{head}").split()) + + +def check_out_the_head(workspace, origin, sha): + """Fetch ``sha`` into ``workspace/head`` as the workflow's checkout does.""" + depth = step_named("Check out the head")["with"].get("fetch-depth", 1) + head = Path(workspace) / "head" + head.mkdir(parents=True) + git(head, "init", "-q") + git(head, "fetch", "-q", "--no-tags", f"--depth={depth}", origin.path.as_uri(), sha) + git(head, "checkout", "-q", "--detach", sha) + + +def choose_the_base(workspace, origin, merge): + check_out_the_head(workspace, origin, merge) + return run_step("Choose the base", workspace, EVENT="pull_request") + + +def test_a_pull_request_is_compared_with_its_merge_commits_first_parent(tmp_path): + origin = Origin(tmp_path / "origin") + # The pull request branches from main at `forked`. The event's + # pull_request.base.sha can still name it after main has moved on. + forked = origin.commit(origin.commit()) + change = origin.commit(forked) + # Main moves on: two later merges. + tip = origin.commit(origin.commit(forked)) + # GitHub's merge commit for the run: main's tip, then the pull request. + merge = origin.commit(tip, change) + + process, outputs = choose_the_base(tmp_path / "runner", origin, merge) + + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": tip} + assert origin.attributed(outputs["sha"], merge) == {merge, change} + # Against `forked`, main's two later merges counted as the pull request's. + assert len(origin.attributed(forked, merge) - {merge, change}) == 2 + + +@pytest.mark.parametrize("parents", [0, 1, 3]) +def test_a_head_that_does_not_merge_two_commits_has_no_base(tmp_path, parents): + origin = Origin(tmp_path / "origin") + head = origin.commit(*[origin.commit() for _ in range(parents)]) + + process, outputs = choose_the_base(tmp_path / "runner", origin, head) + + assert process.returncode != 0 + assert f"::error::The head {head} has {parents} parents, not 2" in process.stdout + assert outputs == {} + + +def test_the_events_base_sha_is_not_the_comparison_base(): + expressions = re.findall(r"\$\{\{(.*?)\}\}", WORKFLOW.read_text(), re.DOTALL) + assert expressions + assert not [e for e in expressions if "pull_request.base.sha" in e] + names = [s.get("name") for s in steps()] + order = [ + names.index("Choose the commits"), + names.index("Check out the head"), + names.index("Choose the base"), + names.index("Check out the base"), + ] + assert order == sorted(order) + head = step_named("Check out the head")["with"] + assert head["ref"] == "${{ steps.commits.outputs.head }}" + choose = step_named("Choose the base") + assert choose["id"] == "base" + assert choose["env"]["BASE"] == "${{ steps.commits.outputs.base }}" + assert step_named("Check out the base")["with"]["ref"] == ( + "${{ steps.base.outputs.sha }}" + ) + + +def test_a_pull_request_head_is_its_merge_commit(tmp_path): + process, outputs = run_step( + "Choose the commits", + tmp_path, + EVENT="pull_request", + GITHUB_SHA=SHA, + PR_BASE_REF="main", + PR_NUMBER="2516", + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == { + "base": "", + "head": SHA, + "base_name": "main", + "head_name": "#2516 merged into main", + } + + +@pytest.mark.parametrize( + "inputs, expected", + [ + ( + {}, + {"base": "main", "head": SHA, "base_name": "main", "head_name": "dev"}, + ), + ( + {"INPUT_BASE": "2.139.0", "INPUT_HEAD": "fix"}, + { + "base": "2.139.0", + "head": "fix", + "base_name": "2.139.0", + "head_name": "fix", + }, + ), + ], +) +def test_a_dispatch_compares_the_commits_it_names(tmp_path, inputs, expected): + process, outputs = run_step( + "Choose the commits", + tmp_path, + EVENT="workflow_dispatch", + GITHUB_SHA=SHA, + GITHUB_REF_NAME="dev", + **inputs, + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == expected + # The base passes through, with no head to read it from. + process, outputs = run_step( + "Choose the base", tmp_path, EVENT="workflow_dispatch", BASE=expected["base"] + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": expected["base"]} + + +@settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + before=st.integers(0, 2), + events=st.lists(st.sampled_from(["main", "pull request", "update"]), max_size=8), +) +def test_no_commit_of_the_base_branch_counts_as_the_pull_requests(before, events): + """Invariant 1, for any history: main's commits before and after the + fork, the pull request's commits, and merges of main into the pull + request ("Update branch").""" + with tempfile.TemporaryDirectory() as directory: + origin = Origin(Path(directory) / "origin") + main = origin.commit() + for _ in range(before): + main = origin.commit(main) + pull_request = origin.commit(main) + own = {pull_request} + merged = main + for event in events: + if event == "main": + main = origin.commit(main) + elif event == "pull request": + pull_request = origin.commit(pull_request) + own.add(pull_request) + elif main != merged: + pull_request = origin.commit(pull_request, main) + own.add(pull_request) + merged = main + merge = origin.commit(main, pull_request) + + process, outputs = choose_the_base(Path(directory) / "runner", origin, merge) + + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": main} + assert origin.attributed(outputs["sha"], merge) == own | {merge} 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 164/224] 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 7d2ad8c851f52938e3e3a98b76e42f5e1f324a70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 04:39:56 -0400 Subject: [PATCH 165/224] Pin the head-alone carer, earnings-disregard and Pension Credit paths; document the carer-attribution limit Review r3 (APPROVE at 47c525cef): - P3-2: four YAML cases pin the head-alone carer condition, her own single earnings disregards and additional-disregard test, and the gated Pension Credit income and capital routes; each catches its mutation. - P2-3: the under-18-head couple case moves from Cardiff to Edinburgh, whose working-age scheme pays the flat couple rate the model uses. - P2-1/P3-1: council_tax_reduction_premiums documents that a carer outside a lone-applying head's benefit unit does not end her severe disability premium (inherited attribution). Co-Authored-By: Claude Opus 5.5 --- .../applicant_or_partner.yaml | 168 +++++++++++++++++- .../council_tax_reduction_premiums.py | 9 +- 2 files changed, 173 insertions(+), 4 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 ec6d85dc67..a4704094d1 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 @@ -409,7 +409,7 @@ is_council_tax_reduction_applicant_or_partner: [true, true] # Below the personal allowance and the National Insurance threshold. council_tax_reduction_applicable_income: [10_000] - # Couple aged 18 or over: 150.15 * 52 = 7,807.80. + # The couple rate where the elder is 18 or over: 150.15 * 52 = 7,807.80. council_tax_reduction_applicable_amount: [7_807.80] # 1,800 - (10,000 - 7,807.80) * 20%. simulated_council_tax_reduction_benunit: [1_361.56] @@ -441,8 +441,8 @@ households: home: members: [head, partner] - country: WALES - local_authority: CARDIFF + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH council_tax: 1_800 savings: 0 output: @@ -682,3 +682,165 @@ # parents are 17, so not non-dependants who bar it. council_tax_reduction_premiums: [4_474.60] council_tax_reduction_applicable_amount: [17_786.60] + +- name: A carer paid for caring for a lone-applying head ends her severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + # Paid for caring for the grandmother. + carers_allowance: 4_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + # A carer benefit is paid for caring for her, so no severe disability + # premium (SI 2012/2885 Sch 2 para 6(2)(a)(iii)), and she is not a carer + # herself. + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + +- name: A working pensioner head who applies alone has the single earnings disregards on her own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + employment_income: 10_000 + weekly_hours: 30 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # Her own earnings, below the personal allowance and with no National + # Insurance over State Pension age. + council_tax_reduction_net_earnings: [10_000] + # The single disregard of £5 a week plus the additional £17.10, which she + # earns by working 30 hours a week at 25 or over (SI 2012/2885 Sch 4 + # paras 2 and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not + # the young couple's work conditions. + council_tax_reduction_pensioner_earnings_disregard: [1_149.20] + council_tax_reduction_applicable_income: [8_850.80] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + private_pension_income: 100_000 + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The benefit unit's claimant and partner, not the head, get + # guarantee credit. + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true] + council_tax_reduction_head_applies_alone: [true] + # Her own £100,000 pension is assessed, not disregarded as a guarantee + # credit recipient's would be (SI 2012/2885 Sch 1 para 13). + simulated_council_tax_reduction_benunit: [0] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's capital + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 20_000 + output: + council_tax_reduction_head_applies_alone: [true] + # Household savings stand in for her capital, above the £16,000 limit; + # a guarantee credit recipient's capital would be disregarded. + council_tax_reduction_assessable_capital: [20_000] + simulated_council_tax_reduction_benunit: [0] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index abde570ea5..a3a7b1633d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -21,7 +21,14 @@ class council_tax_reduction_premiums(Variable): "paid for caring for her, and no other resident aged 18 or over is " "her non-dependant (everyone else in the household, since she has no " "partner or children, unless they receive a qualifying benefit or are " - "blind); and the carer premium if she is entitled to a carer benefit." + "blind); and the carer premium if she is entitled to a carer benefit. " + "Whom a carer benefit is paid for caring for comes from " + "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " + "own benefit unit to her but a carer in another benefit unit only to " + "that unit's claimants and partners. So a carer outside her benefit " + "unit (who must then be blind or on a qualifying benefit, or they " + "would bar the premium as her non-dependant) does not end her severe " + "disability premium, though in law they would." ) definition_period = YEAR unit = GBP From 9186a6e83fea4341700e4d1cfba1c8d1639e7fea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:42:53 +0000 Subject: [PATCH 166/224] Update package version --- CHANGELOG.md | 9 +++++++++ changelog.d/140-hb-pension-deduction-parameter.fixed.md | 1 - changelog.d/2145.fixed.md | 1 - changelog.d/fix-2297-formula-input-export.fixed.md | 1 - pyproject.toml | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) delete mode 100644 changelog.d/140-hb-pension-deduction-parameter.fixed.md delete mode 100644 changelog.d/2145.fixed.md delete mode 100644 changelog.d/fix-2297-formula-input-export.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 926c6a3b0d..3b6f44a66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2.140.1] - 2026-10-20 + +### Fixed + +- - Fixed Housing Benefit applicable income to use the configured pension contribution deduction rate, preserving the current 50% deduction. +- Preserve dataset-supplied values for variables with formulas in default input DataFrame exports, so reloading an export retains inputs such as reported State Pension. Exclude calculated caches and model-generated pseudo-inputs while retaining recorded input storage and branch precedence. +- - Based TV licence charges on licensable viewing and corrected the HBAI free-licence value. + + ## [2.140.0] - 2026-10-20 ### Added diff --git a/changelog.d/140-hb-pension-deduction-parameter.fixed.md b/changelog.d/140-hb-pension-deduction-parameter.fixed.md deleted file mode 100644 index b3b4e5d473..0000000000 --- a/changelog.d/140-hb-pension-deduction-parameter.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Housing Benefit applicable income to use the configured pension contribution deduction rate, preserving the current 50% deduction. diff --git a/changelog.d/2145.fixed.md b/changelog.d/2145.fixed.md deleted file mode 100644 index 8552054381..0000000000 --- a/changelog.d/2145.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Based TV licence charges on licensable viewing and corrected the HBAI free-licence value. diff --git a/changelog.d/fix-2297-formula-input-export.fixed.md b/changelog.d/fix-2297-formula-input-export.fixed.md deleted file mode 100644 index ae78f45baf..0000000000 --- a/changelog.d/fix-2297-formula-input-export.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Preserve dataset-supplied values for variables with formulas in default input DataFrame exports, so reloading an export retains inputs such as reported State Pension. Exclude calculated caches and model-generated pseudo-inputs while retaining recorded input storage and branch precedence. diff --git a/pyproject.toml b/pyproject.toml index 782ecdc505..05731acd4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.140.0" +version = "2.140.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From f12016368d0552886477dd1f20b647fac4681fb0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 05:19:23 -0400 Subject: [PATCH 167/224] 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 168/224] 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 169/224] 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 170/224] 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 171/224] 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 172/224] 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 173/224] 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 ea7c98b5320d5ce984f88e2d100cc853667c4a2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:52:30 +0000 Subject: [PATCH 174/224] Update package version --- CHANGELOG.md | 12 ++++++++++++ .../pension-credit-prescribed-benefits.fixed.md | 1 - .../pension-credit-prescribed-benefits.removed.md | 1 - changelog.d/pension-credit-war-pensions.fixed.md | 1 - pyproject.toml | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) delete mode 100644 changelog.d/pension-credit-prescribed-benefits.fixed.md delete mode 100644 changelog.d/pension-credit-prescribed-benefits.removed.md delete mode 100644 changelog.d/pension-credit-war-pensions.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6f44a66c..062da6ce4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2.141.0] - 2026-10-20 + +### Fixed + +- - Pension Credit income now counts industrial injuries disablement benefit, severe disablement allowance, incapacity benefit and maternity allowance (SPC Regs 2002 reg 15(1)); Savings Credit treats working tax credit, incapacity benefit, severe disablement allowance and maternity allowance as non-qualifying income (reg 9); Housing Benefit income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit. +- - Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme guaranteed income payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Housing Benefit and Council Tax Reduction continue to disregard them in full, including where they take the Pension Credit assessment of a savings-credit-only award. + +### Removed + +- - Removed the unused duplicate parameter `gov.dwp.pension_credit.income.savings_credit_excluded_sources`; `gov.dwp.pension_credit.savings_credit.excluded_income` is the list the model reads. + + ## [2.140.1] - 2026-10-20 ### Fixed diff --git a/changelog.d/pension-credit-prescribed-benefits.fixed.md b/changelog.d/pension-credit-prescribed-benefits.fixed.md deleted file mode 100644 index 1717f0b768..0000000000 --- a/changelog.d/pension-credit-prescribed-benefits.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Pension Credit income now counts industrial injuries disablement benefit, severe disablement allowance, incapacity benefit and maternity allowance (SPC Regs 2002 reg 15(1)); Savings Credit treats working tax credit, incapacity benefit, severe disablement allowance and maternity allowance as non-qualifying income (reg 9); Housing Benefit income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit. diff --git a/changelog.d/pension-credit-prescribed-benefits.removed.md b/changelog.d/pension-credit-prescribed-benefits.removed.md deleted file mode 100644 index 569194bb61..0000000000 --- a/changelog.d/pension-credit-prescribed-benefits.removed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed the unused duplicate parameter `gov.dwp.pension_credit.income.savings_credit_excluded_sources`; `gov.dwp.pension_credit.savings_credit.excluded_income` is the list the model reads. diff --git a/changelog.d/pension-credit-war-pensions.fixed.md b/changelog.d/pension-credit-war-pensions.fixed.md deleted file mode 100644 index 865116033e..0000000000 --- a/changelog.d/pension-credit-war-pensions.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme guaranteed income payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Housing Benefit and Council Tax Reduction continue to disregard them in full, including where they take the Pension Credit assessment of a savings-credit-only award. diff --git a/pyproject.toml b/pyproject.toml index 05731acd4c..692593735c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.140.1" +version = "2.141.0" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 7142a403f5eca9ad3d88677b47ad05e24236d335 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 07:04:12 -0400 Subject: [PATCH 175/224] Qualify the carer-attribution note for under-18 carers; cite Sch 4 para 8(a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r4 P3-1: a carer under 18 in another benefit unit also leaves a head who applies alone with her severe disability premium. P3-2: the £5 single earnings disregard is SI 2012/2885 Sch 4 para 8(a), not para 2. Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction/applicant_or_partner.yaml | 2 +- .../council_tax_reduction_premiums.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 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 a4704094d1..405fc1ffc3 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 @@ -765,7 +765,7 @@ council_tax_reduction_net_earnings: [10_000] # The single disregard of £5 a week plus the additional £17.10, which she # earns by working 30 hours a week at 25 or over (SI 2012/2885 Sch 4 - # paras 2 and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not + # paras 8(a) and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not # the young couple's work conditions. council_tax_reduction_pensioner_earnings_disregard: [1_149.20] council_tax_reduction_applicable_income: [8_850.80] diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index a3a7b1633d..d6bc318621 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -26,9 +26,10 @@ class council_tax_reduction_premiums(Variable): "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " "own benefit unit to her but a carer in another benefit unit only to " "that unit's claimants and partners. So a carer outside her benefit " - "unit (who must then be blind or on a qualifying benefit, or they " - "would bar the premium as her non-dependant) does not end her severe " - "disability premium, though in law they would." + "unit does not end her severe disability premium, though in law they " + "would. Such a carer is under 18, blind or on a qualifying benefit, " + "since any other resident aged 18 or over already bars the premium as " + "her non-dependant." ) definition_period = YEAR unit = GBP From 42c01db91f9f79fce47ee9c51e1b6f67ee46b067 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:12:19 +0000 Subject: [PATCH 176/224] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/hb-pension-age-allowance-cohorts.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/hb-pension-age-allowance-cohorts.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062da6ce4c..a6befec6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.1] - 2026-10-20 + +### Fixed + +- Give pension-age Housing Benefit claimants who attained State Pension age on or after 1 April 2021 (and couples who both did) the lower personal allowance in SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), £238.00 and £363.25 a week in 2026-27, instead of the £256.00 and £383.35 kept by earlier cohorts. England's pensioner Council Tax Reduction applies the same split (SI 2012/2885 Sch 2 para 1); Scotland and Wales keep one rate. Adds `state_pension_age_date`, the day each person attains State Pension age. + + ## [2.141.0] - 2026-10-20 ### Fixed diff --git a/changelog.d/hb-pension-age-allowance-cohorts.fixed.md b/changelog.d/hb-pension-age-allowance-cohorts.fixed.md deleted file mode 100644 index d74617015d..0000000000 --- a/changelog.d/hb-pension-age-allowance-cohorts.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Give pension-age Housing Benefit claimants who attained State Pension age on or after 1 April 2021 (and couples who both did) the lower personal allowance in SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), £238.00 and £363.25 a week in 2026-27, instead of the £256.00 and £383.35 kept by earlier cohorts. England's pensioner Council Tax Reduction applies the same split (SI 2012/2885 Sch 2 para 1); Scotland and Wales keep one rate. Adds `state_pension_age_date`, the day each person attains State Pension age. diff --git a/pyproject.toml b/pyproject.toml index 692593735c..6907dce881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.0" +version = "2.141.1" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 4e08807d1a0710cd8b08868e17eb9d3ca333d36c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 08:14:07 -0400 Subject: [PATCH 177/224] 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 178/224] 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 179/224] 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 180/224] 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 181/224] 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 c281c2e96b20eeb8e0ba8d70d50e251e5e62961a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:17:38 +0000 Subject: [PATCH 182/224] Update package version --- CHANGELOG.md | 8 ++++++++ changelog.d/2515.fixed.md | 1 - changelog.d/2517.fixed.md | 1 - pyproject.toml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/2515.fixed.md delete mode 100644 changelog.d/2517.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a6befec6ca..d15bfd9cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [2.141.2] - 2026-10-20 + +### Fixed + +- - Council Tax Reduction and Income Support read their own pension contribution deduction shares (one-half in each scheme) instead of a hard-coded 0.5, and the Council Tax Reduction pensioner earnings disregards cap at net earnings with the scheme's own share, so a reform of Housing Benefit's share no longer moves them. +- - Carer's Allowance earnings read their pension contribution deduction share (one-half, Computation of Earnings Regulations 1996 regs. 10(4)(b) and 13) from a new parameter, `gov.dwp.carers_allowance.pension_contribution_deduction_rate`, instead of a hard-coded 0.5, so the share can be reformed. + + ## [2.141.1] - 2026-10-20 ### Fixed diff --git a/changelog.d/2515.fixed.md b/changelog.d/2515.fixed.md deleted file mode 100644 index 1154378a6b..0000000000 --- a/changelog.d/2515.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Council Tax Reduction and Income Support read their own pension contribution deduction shares (one-half in each scheme) instead of a hard-coded 0.5, and the Council Tax Reduction pensioner earnings disregards cap at net earnings with the scheme's own share, so a reform of Housing Benefit's share no longer moves them. diff --git a/changelog.d/2517.fixed.md b/changelog.d/2517.fixed.md deleted file mode 100644 index ce59fe4bc0..0000000000 --- a/changelog.d/2517.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Carer's Allowance earnings read their pension contribution deduction share (one-half, Computation of Earnings Regulations 1996 regs. 10(4)(b) and 13) from a new parameter, `gov.dwp.carers_allowance.pension_contribution_deduction_rate`, instead of a hard-coded 0.5, so the share can be reformed. diff --git a/pyproject.toml b/pyproject.toml index 6907dce881..814e13d52c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.1" +version = "2.141.2" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From ffa476e6f654d5a174c511165b38f4d19df41e0c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 20 Oct 2026 15:55:22 +0100 Subject: [PATCH 183/224] 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 184/224] 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 5f4b81c471d2725b8ffe6d99449b795e7358661d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:20:38 +0000 Subject: [PATCH 185/224] Update package version --- CHANGELOG.md | 12 ++++++++++++ changelog.d/aggregate-impact-merge-parent.fixed.md | 1 - .../uc-deductions-floor-reconciliation.fixed.md | 5 ----- pyproject.toml | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 changelog.d/aggregate-impact-merge-parent.fixed.md delete mode 100644 changelog.d/uc-deductions-floor-reconciliation.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d15bfd9cd4..32b8ea1d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2.141.3] - 2026-10-20 + +### Fixed + +- - The Aggregate impact report now compares a pull request's merge commit with that commit's first parent, the tip of the branch it merges into. It used the event's `pull_request.base.sha`, which lags that branch once it moves on, so newer merges to `main` were counted as the pull request's impact. +- - Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. Where the floor binds, deductions are met first and the cap reduction absorbs the rest, however heavily the award is capped. + - Added `uc_benefit_cap_reduction_after_floor`, the benefit cap reduction applied to a Universal Credit award after the protected floor and bounded by the award, leaving the regulation 81 amount `uc_benefit_cap_reduction` gross. + - Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. + - Fixed Universal Credit deductions applying to benefit units that do not claim Universal Credit. Deductions and the applied cap reduction use the same claim test as the award (`claims_universal_credit`), so a claim made when legacy benefits close keeps both. + - Corrected the effective date of the 25% deductions cap to 12 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. + + ## [2.141.2] - 2026-10-20 ### Fixed diff --git a/changelog.d/aggregate-impact-merge-parent.fixed.md b/changelog.d/aggregate-impact-merge-parent.fixed.md deleted file mode 100644 index 09a3a244bd..0000000000 --- a/changelog.d/aggregate-impact-merge-parent.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- The Aggregate impact report now compares a pull request's merge commit with that commit's first parent, the tip of the branch it merges into. It used the event's `pull_request.base.sha`, which lags that branch once it moves on, so newer merges to `main` were counted as the pull request's impact. diff --git a/changelog.d/uc-deductions-floor-reconciliation.fixed.md b/changelog.d/uc-deductions-floor-reconciliation.fixed.md deleted file mode 100644 index e60f1db44f..0000000000 --- a/changelog.d/uc-deductions-floor-reconciliation.fixed.md +++ /dev/null @@ -1,5 +0,0 @@ -- Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. Where the floor binds, deductions are met first and the cap reduction absorbs the rest, however heavily the award is capped. -- Added `uc_benefit_cap_reduction_after_floor`, the benefit cap reduction applied to a Universal Credit award after the protected floor and bounded by the award, leaving the regulation 81 amount `uc_benefit_cap_reduction` gross. -- Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. -- Fixed Universal Credit deductions applying to benefit units that do not claim Universal Credit. Deductions and the applied cap reduction use the same claim test as the award (`claims_universal_credit`), so a claim made when legacy benefits close keeps both. -- Corrected the effective date of the 25% deductions cap to 12 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. diff --git a/pyproject.toml b/pyproject.toml index 814e13d52c..b6744d604a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.2" +version = "2.141.3" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 61b58544d5c2232784ffce651fa926d76ed9b37b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:56:31 +0000 Subject: [PATCH 186/224] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/2188.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/2188.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b8ea1d58..61f0a00e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.4] - 2026-10-20 + +### Fixed + +- A before-load `Scenario` change to DfE, DHSC or DfT spending now constructs: `adjust_budgets` skips a year with no allocation instead of writing NaN, and scales each year's allocation by that year's own budget change without carrying an earlier year's change forward. + + ## [2.141.3] - 2026-10-20 ### Fixed diff --git a/changelog.d/2188.fixed.md b/changelog.d/2188.fixed.md deleted file mode 100644 index 7ea3ab08e7..0000000000 --- a/changelog.d/2188.fixed.md +++ /dev/null @@ -1 +0,0 @@ -A before-load `Scenario` change to DfE, DHSC or DfT spending now constructs: `adjust_budgets` skips a year with no allocation instead of writing NaN, and scales each year's allocation by that year's own budget change without carrying an earlier year's change forward. diff --git a/pyproject.toml b/pyproject.toml index b6744d604a..a308253d79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.3" +version = "2.141.4" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ From 3b466a0b7f89d471eff7745175fe6161f67730d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 16:40:57 -0400 Subject: [PATCH 187/224] 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 76cfc9e79808bef241315ba9e45810ba5540a8db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:03:26 +0000 Subject: [PATCH 188/224] Update package version --- CHANGELOG.md | 7 +++++++ changelog.d/ctr-applicant-means-test.fixed.md | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/ctr-applicant-means-test.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f0a00e93..39d5d012c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2.141.5] - 2026-10-20 + +### Fixed + +- Means-test the Council Tax Reduction applicant and their partner rather than the benefit unit's claimant and partner, so a household head who is liable for the council tax but shares a benefit unit with a young couple is assessed on her own income, allowance, premiums and exemption. + + ## [2.141.4] - 2026-10-20 ### Fixed diff --git a/changelog.d/ctr-applicant-means-test.fixed.md b/changelog.d/ctr-applicant-means-test.fixed.md deleted file mode 100644 index 010dc6840c..0000000000 --- a/changelog.d/ctr-applicant-means-test.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Means-test the Council Tax Reduction applicant and their partner rather than the benefit unit's claimant and partner, so a household head who is liable for the council tax but shares a benefit unit with a young couple is assessed on her own income, allowance, premiums and exemption. diff --git a/pyproject.toml b/pyproject.toml index a308253d79..d48ccc67f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.4" +version = "2.141.5" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ 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 189/224] 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 190/224] 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 191/224] 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 192/224] 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 193/224] 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 194/224] 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 195/224] 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 196/224] 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 197/224] 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 198/224] 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 199/224] 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 200/224] 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 201/224] 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 202/224] 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 203/224] 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 204/224] 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 205/224] 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 206/224] 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 207/224] 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 208/224] 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 209/224] 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 210/224] 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 211/224] 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 212/224] 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 213/224] 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 214/224] 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 215/224] 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 216/224] 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 217/224] 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 218/224] 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 219/224] 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 220/224] 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 221/224] 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 222/224] 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 223/224] 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 224/224] 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 = [