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/changelog.d/2139.fixed.md b/changelog.d/2139.fixed.md new file mode 100644 index 0000000000..1bd5d9bd9b --- /dev/null +++ b/changelog.d/2139.fixed.md @@ -0,0 +1 @@ +- Model the 25p a week State Pension age addition at 80 (SSCBA 1992 s.79) for basic State Pension recipients, never uprated, and take it out of the reported amount before the flat-rate and additional pension split. diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 985576b98a..edd6c4fdd5 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -1,6 +1,6 @@ # State Pension -The State Pension is modelled in PolicyEngine UK as three composable +The State Pension is modelled in PolicyEngine UK as four composable person-level variables, summed into the `state_pension` aggregate that flows into household benefits. This page describes the current model and the residual aggregate gap against OBR outturn tracked under @@ -8,7 +8,7 @@ and the residual aggregate gap against OBR outturn tracked under ## Components -Three variables under `gov/dwp/`: +Four variables under `gov/dwp/`: - **`basic_state_pension`** — the pre-2016 flat-rate State Pension paid to people who reached State Pension age before 6 April 2016 @@ -26,20 +26,83 @@ 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`. - -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). + data year, `max(reported - flat_max_for_type, 0)`, uprated by September + CPI as described below and multiplied by `WEEKS_IN_YEAR`. +- **`state_pension_age_addition`** — the age addition: 25p a week + (`gov.dwp.state_pension.age_addition.amount`) for a person over the age of + 80 (`age_addition.age`) who is entitled to a retirement pension under the + Social Security Contributions and Benefits Act 1992 + ([s.79](https://www.legislation.gov.uk/ukpga/1992/4/section/79) and + [Sch 4 Part III para 8](https://www.legislation.gov.uk/ukpga/1992/4/schedule/4)), + read as a `BASIC` type with a State Pension reported in the data year. It + is never uprated: each Up-rating Order excludes it (for example + [SI 2026/148 art 3(2)(a)](https://www.legislation.gov.uk/uksi/2026/148/article/3/made)). + A new State Pension gets none, because s.79(1) covers only a retirement + pension "under this Act", words the Pensions Act 2014 inserted + ([Sch 12 para 85](https://www.legislation.gov.uk/ukpga/2014/19/schedule/12/paragraph/85)). + A person is over an age once they have attained it (SSCBA 1992 s.173). + The model reads age on 6 October, the middle of the fiscal year, as it + does for State Pension age, so a person who turns 80 by then gets the + year's £13 in full and one who turns 80 later in the year gets none until + the next. + +The survey records the State Pension in payment. For Family Resources +Survey respondents, `state_pension_reported` is the benefits table's weekly +State Pension amount (benefit code 5) times 52, and policyengine-uk-data maps +no separate code for the age addition (`datasets/frs.py`), so the model +assumes the reported amount of a basic State Pension recipient over 80 +includes it. `state_pension_reported_excluding_age_addition` takes the data +year's addition off, at the legislated 25p from 80, and the other three +components split what is left. The addition is part of `state_pension`, so +it is taxed and means-tested as the pension is. It is an increase of the +pension (s.79(1)), so the model taxes it with the pension it increases: +[ITEPA 2003 s.577](https://www.legislation.gov.uk/ukpga/2003/1/section/577) +charges the retirement pensions under SSCBA 1992 ss.44, 48A to 48BB, 51 and +78 as pension income, without naming s.79, and s.677 does not exempt the age +addition (the pre-2003 charge in ICTA 1988 s.617(1) reached every benefit +under Parts II to IV of the 1992 Act not excepted, and the age addition was +not excepted). Pension Credit counts it as retirement pension income +([State Pension Credit Act 2002 s.16(1)(e)](https://www.legislation.gov.uk/ukpga/2002/16/section/16)), +as do Universal Credit (Universal Credit Regulations 2013 regs 66(1)(a) and 67) and +pension-age Housing Benefit (Housing Benefit (State Pension Credit) +Regulations 2006 reg 29(1)(c)). It never reduces Carer's Allowance: an age +addition is adjusted only by reference to another age addition +([Overlapping Benefits Regulations 1979 reg 4(3)](https://www.legislation.gov.uk/uksi/1979/597/regulation/4) +and Sch 1 row 4). + +The basic, new and additional State Pension split the amount a person +reported in the data year (the dataset's first year), less any age addition, +by the person's `state_pension_type` in the year 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 +230,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: @@ -336,6 +412,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 @@ -360,8 +444,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,9 +467,15 @@ 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 | +| The 25p age addition at 80 was not modelled: in the reported amount it was uprated as additional pension, and no one who turned 80 later got it | Fixed | #2139 | ### What's still open +- The Category D retirement pension, for people over 80 with no other + retirement pension or one below its rate (SSCBA 1992 s.78(3)), is not + modelled: [#2339](https://github.com/PolicyEngine/policyengine-uk/issues/2339). + - The data year's shortfall in the enhanced FRS: [policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493). - The pension-age population and new-cohort awards in projected years: diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml new file mode 100644 index 0000000000..817db0ead0 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/age.yaml @@ -0,0 +1,11 @@ +description: A person entitled to a retirement pension under the Social Security Contributions and Benefits Act 1992 gets the age addition from this age. +values: + 1992-07-01: 80 +metadata: + unit: year + label: State Pension age addition age + reference: + - title: Social Security Contributions and Benefits Act 1992 s.79(1) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/79 + - title: Social Security Contributions and Benefits Act 1992 s.173 (a person is over an age once they have attained it) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/173 diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml new file mode 100644 index 0000000000..6ce0906ad2 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/state_pension/age_addition/amount.yaml @@ -0,0 +1,12 @@ +description: The Department for Work and Pensions adds this weekly amount, the age addition, to the retirement pension of a person over the age of 80. Up-rating Orders leave it out of the annual uprating, so it has stayed at 25p. +values: + 1992-07-01: 0.25 +metadata: + period: week + unit: currency-GBP + label: State Pension age addition + reference: + - title: Social Security Contributions and Benefits Act 1992 s.79(3) and Sch 4 Part III para 8 + href: https://www.legislation.gov.uk/ukpga/1992/4/schedule/4 + - title: Social Security Benefits Up-rating Order 2026 art 3(2)(a) (the age addition is not increased) + href: https://www.legislation.gov.uk/uksi/2026/148/article/3/made diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml index 72cf06ab25..8a24135b10 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_overlapping_benefits.yaml @@ -92,7 +92,9 @@ country: ENGLAND output: state_pension_type: BASIC - state_pension: 7_000 + # Aged 80 on an old State Pension, so the 25p age addition is paid too + # (SSCBA 1992 s.79); it does not overlap (SI 1979/597 reg 4(3)). + state_pension: 7_000 + 0.25 * 52 overlapping_state_pension: 2_000 carers_allowance: 86.45 * 52 - 2_000 @@ -119,8 +121,11 @@ care_hours: 35 country: ENGLAND output: + # The reported amount of a person aged 80 includes the 25p age addition + # (SSCBA 1992 s.79), paid on its own. basic_state_pension: 184.90 * 52 - additional_state_pension: 13_000 - 184.90 * 52 + additional_state_pension: 13_000 - 0.25 * 52 - 184.90 * 52 + state_pension_age_addition: 0.25 * 52 overlapping_state_pension: 184.90 * 52 carers_allowance: 0 @@ -133,8 +138,11 @@ care_hours: 35 country: ENGLAND output: - overlapping_state_pension: 3_000 - carers_allowance: 86.45 * 52 - 3_000 + # The 25p age addition in the reported amount does not overlap: an age + # addition is adjusted only by reference to another age addition (SI + # 1979/597 reg 4(3); Sch 1 row 4, "except any age addition"). + overlapping_state_pension: 3_000 - 0.25 * 52 + carers_allowance: 86.45 * 52 - (3_000 - 0.25 * 52) - name: Case 9, a reported new State Pension below the allowance. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml new file mode 100644 index 0000000000..40751d4452 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_addition.yaml @@ -0,0 +1,167 @@ +# The age addition: 25p a week for a person over the age of 80 who is entitled +# to a retirement pension under the Social Security Contributions and Benefits +# Act 1992 (s.79(1) and (3), Sch 4 Part III para 8), never uprated (each +# Up-rating Order excludes it, e.g. SI 2026/148 art 3(2)(a)). A person is over +# an age once they have attained it (s.173). A new State Pension, under Part 1 +# of the Pensions Act 2014, is not a pension under that Act (s.79(1) as amended +# by the Pensions Act 2014 Sch 12 para 85). Annual amount: 0.25 x 52 = 13. + +- name: Case 1, aged 80 on the basic State Pension gets the age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + is_SP_age: true + +- name: Case 2, aged 79 gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 79 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 0 + +- name: Case 3, aged 85 on the new State Pension gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: NEW + state_pension_reported: 12_000 + output: + state_pension_age_addition: 0 + +- name: Case 4, below State Pension age gets no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + state_pension_reported: 9_100 + output: + state_pension_type: NONE + state_pension_age_addition: 0 + +- name: Case 5, the age addition stays 25p a week in 2026-27. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + +- name: Case 6, the age addition stays 25p a week in 2030-31. + period: 2030 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension_type: BASIC + state_pension_reported: 9_100 + output: + state_pension_age_addition: 13 + +- name: Case 7, no State Pension means no age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 0 + output: + state_pension_age_addition: 0 + state_pension: 0 + +- name: Case 8, the computed State Pension type of a man aged 80 is basic. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + gender: MALE + state_pension_reported: 9_100 + output: + state_pension_type: BASIC + state_pension_age_addition: 13 + +- name: Case 9, a reported full basic rate plus 25p is the full rate and the addition. + # 2025-26 basic State Pension £176.45 a week (SI 2025/295 art 4(2)(b)). The + # reported £176.70 a week is the full rate and the age addition, so there is + # no additional State Pension. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 9_188.4 + output: + basic_state_pension: 9_175.4 + additional_state_pension: 0 + state_pension_age_addition: 13 + state_pension: 9_188.4 + state_pension_reported_excluding_age_addition: 9_175.4 + +- name: Case 10, the age addition does not reduce Carer's Allowance. + # Overlapping Benefits Regulations 1979 reg 4(3) and Sch 1 row 4: an age + # addition is adjusted only by reference to another age addition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 9_188.4 + output: + overlapping_state_pension: 9_175.4 + +- name: Case 11, State Pension components supplied directly also bring the age addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 82 + state_pension_type: BASIC + basic_state_pension: 2_000 + output: + state_pension_age_addition: 13 + state_pension: 2_013 + +- name: Case 12, a reported amount of exactly the age addition is the age addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 85 + state_pension_type: BASIC + state_pension_reported: 13 + output: + basic_state_pension: 0 + additional_state_pension: 0 + state_pension_age_addition: 13 + state_pension: 13 diff --git a/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py b/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py index 8bb3314a77..355508bd56 100644 --- a/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py +++ b/policyengine_uk/tests/test_carer_overlapping_benefits_properties.py @@ -215,6 +215,9 @@ def rates(sim): * WEEKS, "basic": float(parameters.gov.dwp.state_pension.basic_state_pension.amount) * WEEKS, + "age_addition": float(parameters.gov.dwp.state_pension.age_addition.amount) + * WEEKS, + "age_addition_age": float(parameters.gov.dwp.state_pension.age_addition.age), "min_hours": float(parameters.gov.dwp.carers_allowance.min_hours), "carer_addition": float( parameters.gov.dwp.pension_credit.guarantee_credit.carer.addition @@ -246,9 +249,17 @@ def reference(people, sim, extra=None): # The model's split of a reported amount: an old State Pension # above the full basic rate is additional pension, which does not # overlap. A new State Pension overlaps in full, protected payment - # included, and there is none before pensionable age. + # included, and there is none before pensionable age. From 80 an + # old State Pension is taken to include the 25p age addition, + # which does not overlap either (SI 1979/597 reg 4(3)). + with_age_addition = ( + pension_type == "BASIC" + and person["age"] >= r["age_addition_age"] + and pension > 0 + ) + basic_reported = max(pension - r["age_addition"] * with_age_addition, 0) state_pension = { - "BASIC": min(pension, r["basic"]), + "BASIC": min(basic_reported, r["basic"]), "NEW": pension, "NONE": 0, }[pension_type] diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 439e77a639..e2fdda3c63 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -7,20 +7,28 @@ 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 +age addition, 25p a week from 80 on a basic State Pension, is paid on its own +and never uprated, so the components split the reported amount without it +(see net_weekly). 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 -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st 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 +92,108 @@ 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) + + +# The age addition: 25p a week for a person over the age of 80 entitled to a +# retirement pension under the Social Security Contributions and Benefits Act +# 1992, which a new State Pension is not (s.79(1) and (3), Sch 4 Part III +# para 8), never uprated (e.g. SI 2026/148 art 3(2)(a)). The reported amount +# is taken to include it. +AGE_ADDITION_WEEKLY = 0.25 +AGE_ADDITION_AGE = 80 +AGE_ADDITION = AGE_ADDITION_WEEKLY * WEEKS_IN_YEAR + + +def receives_age_addition(pension_type, age, weekly): + """Basic State Pension type, aged 80 or over and some State Pension.""" + return ( + (np.asarray(pension_type) == "BASIC") + & (np.asarray(age) >= AGE_ADDITION_AGE) + & (np.asarray(weekly) > 0) ) +def net_weekly(person: dict, data_year_type: str) -> float: + """The weekly amount the components split: the reported amount less the + age addition it includes in the data year.""" + receives = receives_age_addition(data_year_type, person["age"], person["weekly"]) + return max(person["weekly"] - AGE_ADDITION_WEEKLY * bool(receives), 0) + + def calculate(sim, variable: str, year: int) -> np.ndarray: return np.asarray(sim.calculate(variable, year)) @@ -134,7 +226,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 +235,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) ) @@ -160,13 +251,16 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): ], ) def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): + """The 90-year-old's reported amount includes the 25p age addition, which + is paid on its own; the 70-year-old, on the new State Pension, gets none.""" sim = projected_simulation([person]) + weekly = net_weekly(person, pension_type) + addition = AGE_ADDITION if pension_type == "BASIC" else 0 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, weekly)) + assert calculate(sim, "state_pension_age_addition", year)[0] == addition def test_nothing_is_paid_once_the_record_falls_below_state_pension_age(): @@ -200,10 +294,176 @@ 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: the 25p age addition and £30.25 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)), and left the age addition at 25p (art 3(2)(a)), so in + 2026-27 he gets £184.90 a week of basic State Pension, £30.25 x 1.017 x + 1.038 of additional State Pension and 25p.""" + 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 = (199.75 - 169.50) * WEEKS_IN_YEAR * 1.017 + additional_2026 = (199.75 - 169.50) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", DATA_YEAR)[0] == approx( + (199.75 - 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_age_addition", 2026)[0] == AGE_ADDITION + assert calculate(sim, "state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + additional_2026 + AGE_ADDITION + ) + + +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( + "change", + [ + # Full rates set by reform from 2026-27: the add-on is not scaled with + # them. + { + "reform": { + "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": 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(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), **change) + 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 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.01 + }, + }, + applied_before_data_load=True, + ) + 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.01, abs=INDEX_ROUNDING) + assert flat_uprating(sim, 2028, "BASIC") / flat_uprating(sim, 2027, "BASIC") >= ( + 1.025 - INDEX_ROUNDING ) - assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) # Each example builds a microsimulation, so examples are slow to generate. @@ -221,10 +481,11 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): "age": st.integers(min_value=55, max_value=100), "male": st.booleans(), "months": st.floats(min_value=0, max_value=12, exclude_max=True), - # Include each data-year flat rate, where the split changes. + # Include each data-year flat rate, where the split changes, the + # age addition alone, and the basic rate with the age addition. "weekly": st.one_of( st.floats(min_value=0, max_value=600), - st.sampled_from([0, 169.5, 221.2]), + st.sampled_from([0, 0.25, 169.5, 169.75, 221.2]), ), } ), @@ -237,13 +498,39 @@ def by_type(pension_type: np.ndarray, basic, new) -> np.ndarray: return np.select([pension_type == "BASIC", pension_type == "NEW"], [basic, new], 0) +def stored_weekly(people: list) -> np.ndarray: + """Each record's reported weekly amount as the simulation holds it. + state_pension_reported is a float variable, which the model stores in + float32, so an amount too small for float32 (Hypothesis draws amounts down + to 5e-324 a week) is held as nil: the record reports no State Pension.""" + annual = np.array([p["weekly"] * WEEKS_IN_YEAR for p in people], np.float32) + return annual.astype(float) / WEEKS_IN_YEAR + + +def data_year_net_weekly(sim, people: list) -> np.ndarray: + """Each record's reported weekly amount less the age addition it includes + in the data year.""" + weekly = stored_weekly(people) + included = receives_age_addition( + calculate(sim, "state_pension_type", DATA_YEAR).astype(str), + calculate(sim, "age", DATA_YEAR), + weekly, + ) + return np.maximum(weekly - AGE_ADDITION_WEEKLY * included, 0) + + def assert_components_add_up(sim, people: list, years) -> None: """For each person and year: below State Pension age, no component and type NONE. Over it, only the period type's flat-rate component, which is the - reported amount up to the type's full rate, uprated by the full rate; and, - deflating each component by its own uprating, the three components add up - to exactly the reported amount, with no overlap and no gap.""" - weekly = np.array([p["weekly"] for p in people]) + reported amount net of the age addition up to the type's full rate, + uprated by the full rate; and, deflating each component by its own + uprating, the three components add up to exactly that net amount, with no + overlap and no gap. The age addition is 25p a week exactly when the record + is on the basic State Pension, aged 80 or over and has a State Pension, in + every year; in the data year the four parts add up to the reported amount + whenever it is at least 25p a week.""" + reported_weekly = stored_weekly(people) + weekly = data_year_net_weekly(sim, people) for year in years: pension_type = calculate(sim, "state_pension_type", year).astype(str) over_spa = calculate(sim, "is_SP_age", year).astype(bool) @@ -255,11 +542,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 +550,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) @@ -278,10 +560,36 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.all(additional >= 0) assert np.all(basic <= rates["BASIC"] * WEEKS_IN_YEAR * (1 + 1e-6)) assert np.all(new <= rates["NEW"] * WEEKS_IN_YEAR * (1 + 1e-6)) + addition = calculate(sim, "state_pension_age_addition", year) + receives = receives_age_addition( + pension_type, calculate(sim, "age", year), reported_weekly + ) + assert np.array_equal(addition, receives * AGE_ADDITION), year + if year == DATA_YEAR: + total = basic + new + additional + addition + full_record = over_spa & (reported_weekly >= AGE_ADDITION_WEEKLY) + assert np.allclose( + total[full_record], + reported_weekly[full_record] * WEEKS_IN_YEAR, + **FLOAT32, + ) + + +# Reported amounts too small for float32, held as nil (CI run 38170838155 +# found the first), and a tiny amount float32 still holds, at 80 on the basic +# State Pension. +TINY_REPORTS = [ + [{"age": 80, "male": False, "months": 0.0, "weekly": 8.266518416092618e-246}], + [{"age": 80, "male": False, "months": 0.0, "weekly": 2.2250738585e-313}], + [{"age": 85, "male": True, "months": 3.0, "weekly": 1e-30}], +] @PROPERTY_SETTINGS @given(people=PEOPLE) +@example(people=TINY_REPORTS[0]) +@example(people=TINY_REPORTS[1]) +@example(people=TINY_REPORTS[2]) def test_components_add_up_to_uprated_reported_amount(people): """Data projected with ages held fixed, every year from 2024-25 to 2030-31.""" sim = projected_simulation(people) @@ -314,10 +622,137 @@ 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)) + + +# 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 + [ + {**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, applied to the reported amount net of the age + addition: 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 = data_year_net_weekly(sim, 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, **FLOAT32) + assert np.allclose(new, main_new, **FLOAT32) + if year == DATA_YEAR: + assert np.allclose(additional, main_additional, **FLOAT32) + 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=1e-6, + ) + 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 +766,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, @@ -386,20 +825,23 @@ def test_situation_new_state_pension_follows_a_dated_rate_reform(reformed): def test_situation_basic_state_pension_follows_a_dated_rate_reform(): + """Aged 90, reporting the full basic rate and the 25p age addition.""" year = 2026 - weekly = LEGISLATED[(year, "BASIC")] + weekly = LEGISLATED[(year, "BASIC")] + AGE_ADDITION_WEEKLY sit = situation(90, "BASIC", weekly, year) reform = {BSP: {f"{year}-01-01.{year}-12-31": 170.0}} result = situation_components(sit, reform, year) assert result["basic_state_pension"] == pytest.approx(170 * WEEKS_IN_YEAR, abs=0.01) assert result["additional_state_pension"] == 0 - assert result["state_pension"] == pytest.approx(170 * WEEKS_IN_YEAR, abs=0.01) + assert result["state_pension"] == pytest.approx( + 170 * WEEKS_IN_YEAR + AGE_ADDITION, abs=0.01 + ) 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,32 +866,55 @@ 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 + net_weekly(person, pension_type), + LEGISLATED[(DATA_YEAR, pension_type)], + reformed, + add_on_uprating(sim, year), ) + addition = AGE_ADDITION if pension_type == "BASIC" else 0 flat_variable = ( "new_state_pension" if pension_type == "NEW" else "basic_state_pension" ) 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"] + addition + ) + + +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 +925,66 @@ 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"] + ) + + +# The age addition (PolicyEngine/policyengine-uk#2139). + + +def test_full_basic_rate_plus_age_addition_is_never_additional_pension(): + """A man aged 85 in 2024-25 reports £169.75 a week: the full basic State + Pension (£169.50, Social Security Benefits Up-rating Order 2024 art 4(2)) + and the 25p age addition. In every year he gets the full basic rate and + 25p, never uprated, and no additional State Pension. Before #2139 the 25p + was additional pension, uprated with it.""" + person = {"age": 85, "male": True, "months": 3, "weekly": 169.75} + sim = projected_simulation([person]) + for year in YEARS: + assert calculate(sim, "state_pension_type", year)[0] == "BASIC" + basic = flat_rates(sim, year)["BASIC"] * WEEKS_IN_YEAR + assert calculate(sim, "basic_state_pension", year)[0] == approx(basic) + assert calculate(sim, "additional_state_pension", year)[0] == 0 + assert calculate(sim, "state_pension_age_addition", year)[0] == AGE_ADDITION + assert calculate(sim, "state_pension", year)[0] == approx(basic + AGE_ADDITION) + + +def test_age_addition_reforms_leave_the_reported_split_alone(): + """An undated reform paying £10 a week from 75 pays it to both records + while they are on the basic State Pension, but the reported amounts were + observed under the legislated 25p from 80: the 85-year-old's is still + split net of 25p, and the 77-year-old's in full, so the other components + do not change. Held at 77, the second record is on the new State Pension + by 2030-31, and a new State Pension gets no age addition, reformed or not: + the addition belongs to a retirement pension under the 1992 Act + (s.79(1)).""" + people = [ + {"age": 85, "male": True, "months": 3, "weekly": 169.75}, + {"age": 77, "male": True, "months": 3, "weekly": 169.5}, + ] + baseline = projected_simulation(people) + reformed = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), + reform={ + "gov.dwp.state_pension.age_addition.amount": 10.0, + "gov.dwp.state_pension.age_addition.age": 75, + }, + ) + types = { + DATA_YEAR: ["BASIC", "BASIC"], + 2027: ["BASIC", "BASIC"], + 2030: ["BASIC", "NEW"], + } + for year, year_types in types.items(): + assert list(calculate(reformed, "state_pension_type", year)) == year_types + assert list(calculate(reformed, "state_pension_age_addition", year)) == [ + 10 * WEEKS_IN_YEAR if t == "BASIC" else 0 for t in year_types + ], year + for variable in COMPONENTS: + assert calculate(reformed, variable, year) == pytest.approx( + calculate(baseline, variable, year), abs=1e-6 + ), (variable, year) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index 932e7ff563..a864fdb4ab 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 @@ -18,7 +24,12 @@ def formula(person, period, parameters): data_year = period.start.year else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) # Split by the period's type, as basic_state_pension and # new_state_pension do, so the three components add up to the # reported amount. Survey ages are held fixed across years, so a @@ -29,21 +40,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 +66,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) diff --git a/policyengine_uk/variables/gov/dwp/basic_state_pension.py b/policyengine_uk/variables/gov/dwp/basic_state_pension.py index 23d4a7a883..84e39f7499 100644 --- a/policyengine_uk/variables/gov/dwp/basic_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/basic_state_pension.py @@ -21,7 +21,12 @@ def formula(person, period, parameters): else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) pension_type = person("state_pension_type", period) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. diff --git a/policyengine_uk/variables/gov/dwp/new_state_pension.py b/policyengine_uk/variables/gov/dwp/new_state_pension.py index f26e4a9cfd..79fa7019c1 100644 --- a/policyengine_uk/variables/gov/dwp/new_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/new_state_pension.py @@ -23,7 +23,12 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) eligible = pension_type == pension_type.possible_values.NEW - reported_weekly = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + # The age addition is paid by state_pension_age_addition, so split + # the reported amount without it. + reported_weekly = ( + person("state_pension_reported_excluding_age_addition", data_year) + / WEEKS_IN_YEAR + ) # 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 diff --git a/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py b/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py index f48729090d..70b03718bc 100644 --- a/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/overlapping_state_pension.py @@ -9,7 +9,8 @@ class overlapping_state_pension(Variable): "The State Pension by which Carer's Allowance and Carer Support Payment " "are reduced. A new State Pension under Part 1 of the Pensions Act 2014 " "overlaps in full, including any protected payment above the full rate. " - "An old State Pension overlaps without its additional pension. When " + "An old State Pension overlaps without its additional pension or its " + "age addition. When " "only a reported total is available, the model estimates its basic " "component up to the full basic rate and treats the excess as " "additional pension. This split cannot identify basic-pension " @@ -22,25 +23,35 @@ class overlapping_state_pension(Variable): reference = ( "https://www.legislation.gov.uk/uksi/1979/597/regulation/4", "https://www.legislation.gov.uk/uksi/1979/597/regulation/12", + "https://www.legislation.gov.uk/uksi/1979/597/schedule/1", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", ) def formula(person, period, parameters): state_pension = person("state_pension", period) additional = person("additional_state_pension", period) + # An age addition is adjusted only by reference to another age + # addition (SI 1979/597 reg 4(3); Sch 1 row 4, "except any age + # addition"), so it never reduces a carer benefit. + age_addition = person("state_pension_age_addition", period) components = add( person, period, - ["basic_state_pension", "new_state_pension", "additional_state_pension"], + [ + "basic_state_pension", + "new_state_pension", + "additional_state_pension", + "state_pension_age_addition", + ], ) pension_type = person("state_pension_type", period) old_system = pension_type == pension_type.possible_values.BASIC # state_pension can scale its components (a contributed reform does), - # so take the additional pension as a share of them. - additional_share = np.divide( - additional, + # so take the parts that do not overlap as a share of them. + not_overlapping_share = np.divide( + old_system * additional + age_addition, components, out=np.zeros_like(components, dtype=float), where=components > 0, ) - return state_pension * (1 - old_system * additional_share) + return state_pension * (1 - not_overlapping_share) diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py new file mode 100644 index 0000000000..c824f4053e --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_addition.py @@ -0,0 +1,64 @@ +from policyengine_uk.model_api import * + + +def state_pension_data_year(person, period) -> int: + """The year state_pension_reported was observed: the dataset's first year, + or the period itself in a simulation without a dataset.""" + simulation = person.simulation + if simulation.dataset is not None: + try: + return min(simulation.dataset.years) + except Exception: + return period.start.year + return period.start.year + + +def over_age_on_basic_state_pension(person, period, age_addition_age): + """Over the age of 80 with a basic State Pension type. The age addition + goes to a person over 80 entitled to a retirement pension under the Social + Security Contributions and Benefits Act 1992 (s.79(1)); a person is over + an age once they have attained it (s.173). A new State Pension, under Part + 1 of the Pensions Act 2014, is not a pension "under this Act" (s.79(1) as + amended by the Pensions Act 2014 Sch 12 para 85).""" + pension_type = person("state_pension_type", period) + basic = pension_type == pension_type.possible_values.BASIC + return basic & (person("age", period) >= age_addition_age) + + +class state_pension_age_addition(Variable): + label = "State Pension age addition" + documentation = ( + "The age addition: 25p a week added to a retirement pension under the " + "Social Security Contributions and Benefits Act 1992 (the basic State " + "Pension) once its recipient is over 80, and never uprated. A new " + "State Pension gets none. Entitlement to the pension is read from a " + "State Pension reported in the data year or, in a simulation given " + "the components directly, basic or additional State Pension in the " + "year. The model is annual and, as for State Pension age, reads age " + "on 6 October, the middle of the fiscal year: a person who turns 80 " + "by then gets the whole year's addition, and one who turns 80 later " + "in the year gets none until the next." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/79", + "https://www.legislation.gov.uk/ukpga/1992/4/section/173", + "https://www.legislation.gov.uk/ukpga/1992/4/schedule/4", + "https://www.legislation.gov.uk/ukpga/2014/19/schedule/12/paragraph/85", + "https://www.legislation.gov.uk/uksi/2026/148/article/3/made", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.state_pension.age_addition + data_year = state_pension_data_year(person, period) + reported = person("state_pension_reported", data_year) > 0 + components = ( + add(person, period, ["basic_state_pension", "additional_state_pension"]) > 0 + ) + receives = over_age_on_basic_state_pension(person, period, p.age) & ( + reported | components + ) + return receives * p.amount * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py new file mode 100644 index 0000000000..8fd548c218 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported_excluding_age_addition.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.state_pension_age_addition import ( + over_age_on_basic_state_pension, +) + + +class state_pension_reported_excluding_age_addition(Variable): + label = "reported State Pension excluding the age addition" + documentation = ( + "state_pension_reported less the age addition it is taken to include. " + "The survey records the State Pension a person receives, which for a " + "basic State Pension recipient over 80 includes the 25p a week age " + "addition. basic_state_pension, new_state_pension and " + "additional_state_pension split this amount, and " + "state_pension_age_addition pays the addition, so it is neither " + "counted twice nor uprated as additional pension." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/79" + + def formula(person, period, parameters): + reported = person("state_pension_reported", period) + # state_pension_reported was observed under the legislated rules, so + # take off the legislated age addition, not a reformed one, as the + # components split it at the legislated full rate + # (PolicyEngine/policyengine-uk#2122). + baseline = getattr(person.simulation, "baseline", None) + legislated = ( + baseline.tax_benefit_system.parameters + if baseline is not None + else parameters + ) + p = legislated(period).gov.dwp.state_pension.age_addition + receives = over_age_on_basic_state_pension(person, period, p.age) & ( + reported > 0 + ) + included = receives * p.amount * WEEKS_IN_YEAR + return max_(reported - included, 0) diff --git a/policyengine_uk/variables/input/state_pension.py b/policyengine_uk/variables/input/state_pension.py index e853011939..347194740a 100644 --- a/policyengine_uk/variables/input/state_pension.py +++ b/policyengine_uk/variables/input/state_pension.py @@ -23,6 +23,7 @@ def formula(person, period, parameters): "basic_state_pension", "additional_state_pension", "new_state_pension", + "state_pension_age_addition", ], ) * uprating