From 2d31a50206d047a8143cd28ccf5ecf7a1b513b5f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 10:35:26 -0400 Subject: [PATCH 01/29] =?UTF-8?q?Raise=20the=20trading=20allowance=20to=20?= =?UTF-8?q?=C2=A33,000=20from=206=20April=202027?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget 2026 paragraph 6.254 and the policy costings (page 23) raise the Income Tax trading allowance from £1,000 to £3,000 from 6 April 2027, to be legislated in Finance Bill 2026-27 (Table 6.1, item 15). The £1,000 YAML cases move to 2026-27; new 2027-28 cases cover full relief at £3,000, the cliff just above it, partial relief and expenses above the allowance. The property test's allowance and boundary draws move to £3,000. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ab26-trading-allowance.changed.md | 1 + .../allowances/trading_allowance.yaml | 12 +++ .../trading_allowance_deduction.yaml | 89 ++++++++++++++++--- .../test_trading_allowance_properties.py | 9 +- 4 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 changelog.d/ab26-trading-allowance.changed.md diff --git a/changelog.d/ab26-trading-allowance.changed.md b/changelog.d/ab26-trading-allowance.changed.md new file mode 100644 index 0000000000..dfe6a2a935 --- /dev/null +++ b/changelog.d/ab26-trading-allowance.changed.md @@ -0,0 +1 @@ +Raise the trading allowance to £3,000 from 6 April 2027 (Budget 2026). diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml index bbaa6d997b..d035e45d7d 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml @@ -12,6 +12,18 @@ values: href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AD - title: Finance (No. 2) Act 2017 Sch. 3 href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 + # Budget 2026 raises the allowance to £3,000 from 6 April 2027, to be + # legislated in Finance Bill 2026-27. The s. 783AD amount also relieves + # miscellaneous income (s. 783B); the model applies it to trading income. + 2027-04-06: + value: 3_000 + reference: + - title: Budget 2026, paragraph 6.254 (Increase Trading Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 23 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=23 + - title: Budget 2026, Table 6.1, item 15 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx metadata: label: Trading allowance period: year diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml index bb1a8391de..7b2bef09c9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml @@ -1,10 +1,12 @@ # Trading allowance, ITTOIA 2005 Part 6A Chapter 1 (ss. 783A-783AR). # self_employment_income is profit after actual expenses. The allowance is an # alternative to those expenses (s. 783AI; HMRC BIM86050), so it may only add -# the part of the £1,000 that actual deductions have not already used. +# the part of the allowance that actual deductions have not already used. +# The allowance is £1,000 to 2026-27 and £3,000 from 2027-28 (Budget 2026, +# paragraph 6.254); the cases above the 2027-28 block use 2026-27. - name: Sole trader with £25,000 profit gets no allowance on top of expenses - period: 2027 + period: 2026 absolute_error_margin: 0.01 input: self_employment_income: 25_000 @@ -16,7 +18,7 @@ income_tax: 2_486 - name: Profit within the allowance, receipts unknown, is fully relieved - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 600 @@ -25,7 +27,7 @@ taxable_self_employment_income: 0 - name: Profit equal to the allowance, receipts unknown, is fully relieved - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 1_000 @@ -34,7 +36,7 @@ taxable_self_employment_income: 0 - name: Profit above the allowance, receipts unknown, is taxed as reported - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 1_001 @@ -43,7 +45,7 @@ taxable_self_employment_income: 1_001 - name: Full relief when receipts are within the allowance (s. 783AE-783AF) - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 800 @@ -53,7 +55,7 @@ taxable_self_employment_income: 0 - name: Partial relief beats £100 of expenses (s. 783AI) - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_200 @@ -64,7 +66,7 @@ taxable_self_employment_income: 200 - name: Partial relief beats £500 of expenses - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_200 @@ -74,7 +76,7 @@ taxable_self_employment_income: 200 - name: Actual expenses above the allowance leave no extra relief - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 25_000 @@ -84,7 +86,7 @@ taxable_self_employment_income: 20_000 - name: The allowance replaces capital allowances as well as expenses - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_500 @@ -96,7 +98,7 @@ taxable_self_employment_income: 500 - name: Expenses plus capital allowances above the allowance use the profit method - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_500 @@ -117,7 +119,7 @@ taxable_self_employment_income: 600 - name: Receipts below profit are inconsistent and treated as unknown - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 500 @@ -125,3 +127,66 @@ output: trading_allowance_deduction: 0 taxable_self_employment_income: 1_500 + +# 2027-28: £3,000 from 6 April 2027 (Budget 2026, paragraph 6.254; policy +# costings page 23). + +- name: 2026-27 profit of £2,500, receipts unknown, is taxed as reported + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: 2027-28 profit of £2,500, receipts unknown, is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + output: + trading_allowance_deduction: 2_500 + taxable_self_employment_income: 0 + +- name: 2027-28 profit equal to the £3,000 allowance is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 3_000 + output: + trading_allowance_deduction: 3_000 + taxable_self_employment_income: 0 + +- name: 2027-28 profit above the £3,000 allowance, receipts unknown, is taxed as reported + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 3_001 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 3_001 + +- name: 2027-28 partial relief replaces £1,000 of expenses with £3,000 + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 5_000 + self_employment_income: 4_000 + output: + trading_allowance_deduction: 2_000 + # 5,000 receipts - 3,000 allowance + taxable_self_employment_income: 2_000 + # (30,000 + 2,000 - 12,570) * 20% + income_tax: 3_886 + +- name: 2027-28 actual expenses above £3,000 leave no extra relief + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 6_000 + self_employment_income: 2_500 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 diff --git a/policyengine_uk/tests/test_trading_allowance_properties.py b/policyengine_uk/tests/test_trading_allowance_properties.py index f24978ca8b..1835606357 100644 --- a/policyengine_uk/tests/test_trading_allowance_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_properties.py @@ -17,15 +17,18 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2027 -ALLOWANCE = 1_000 +# £3,000 from 6 April 2027 (Budget 2026, paragraph 6.254); £1,000 before. +ALLOWANCE = 3_000 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 30_000, 60_000, 110_000, 200_000] CASES = 24 # Each case is simulated twice: as drawn, and with more profit. PEOPLE = 2 * CASES amount = st.one_of( - st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=7_500), st.floats(min_value=0, max_value=300_000), - st.sampled_from([0.0, 999.99, 1_000.0, 1_000.01, 12_570.0, 50_270.0]), + st.sampled_from( + [0.0, 999.99, 1_000.0, 2_999.99, 3_000.0, 3_000.01, 12_570.0, 50_270.0] + ), ) case = st.tuples( st.one_of(amount, st.floats(min_value=-5_000, max_value=0)), # profit From e95275e2d9de60875eb8f94dcfa09509918f26a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 10:35:57 -0400 Subject: [PATCH 02/29] Raise the savings and property income rates by 2 more points from 2027-28 Budget 2026 paragraphs 6.246 and 6.247 add 2 percentage points to the savings and property rates from 6 April 2027, on top of Budget 2025's 22%, 42% and 47%: 24%, 44% and 49% (Table 6.1, items 61 and 62). Property rates apply in England, Wales and Northern Ireland, which the model already does (Scottish taxpayers' property income stays on the Scottish rates); savings rates apply UK-wide. Finance cost relief reads the property basic rate, so it rises to 24%, as the costing note says. The ISA cash-interest charge reads the savings basic rate (SI 2026/1018 reg. 22A(3)), so it rises too. Tests: hard-coded 22/42/47% expectations recomputed at 24/44/49%; new Budget 2026 YAML cases for each band and country; property tests for the exact 2-point rise, Scotland's exclusion from property rates, UK-wide savings rates and the finance cost relief bound. Co-Authored-By: Claude Opus 5.5 --- .../ab26-savings-property-rates.changed.md | 1 + .../gov/hmrc/income_tax/rates/property.yaml | 40 ++++- .../gov/hmrc/income_tax/rates/savings.yaml | 40 ++++- .../income/uc_earnings_deductions.yaml | 8 +- .../property_allowance_deduction.yaml | 4 +- .../income_tax/property_income_band_base.yaml | 4 +- .../reliefs/property_finance_cost_relief.yaml | 16 +- .../baseline/gov/hmrc/isa/isa_income.yaml | 7 +- .../tests/policy/integration/income.yaml | 5 +- .../income_source_tax_rates.yaml | 56 +++--- .../savings_property_income_rates.yaml | 135 +++++++++++++++ ..._2026_savings_property_rates_properties.py | 159 ++++++++++++++++++ .../liability/property_income_tax.py | 10 +- 13 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 changelog.d/ab26-savings-property-rates.changed.md create mode 100644 policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml create mode 100644 policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py diff --git a/changelog.d/ab26-savings-property-rates.changed.md b/changelog.d/ab26-savings-property-rates.changed.md new file mode 100644 index 0000000000..0e141c7896 --- /dev/null +++ b/changelog.d/ab26-savings-property-rates.changed.md @@ -0,0 +1 @@ +Raise the savings and property income rates to 24%, 44% and 49% from 6 April 2027 (Budget 2026). diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml index d383f5421d..3aab150eb3 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml @@ -1,10 +1,12 @@ -description: Property income tax rates. From April 2027, property income is taxed at rates 2pp higher than the standard UK rates. +description: Property income tax rates. From April 2027, property income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. metadata: label: Property Income Tax rates unit: /1 reference: - title: OBR Economic and Fiscal Outlook November 2025 href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 basic: description: The basic rate of tax on property income. metadata: @@ -12,11 +14,19 @@ basic: unit: /1 values: 2015-04-06: 0.20 + # Budget 2025 set 22% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.22 + value: 0.24 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ higher: description: The higher rate of tax on property income. @@ -25,11 +35,19 @@ higher: unit: /1 values: 2015-04-06: 0.40 + # Budget 2025 set 42% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.42 + value: 0.44 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ additional: description: The additional rate of tax on property income. @@ -38,9 +56,17 @@ additional: unit: /1 values: 2015-04-06: 0.45 + # Budget 2025 set 47% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.47 + value: 0.49 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml index 1f160ef4ab..96322ee4df 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml @@ -1,10 +1,12 @@ -description: Savings income tax rates. From April 2027, savings income is taxed at rates 2pp higher than the standard UK rates. +description: Savings income tax rates. From April 2027, savings income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. metadata: label: Savings Income Tax rates unit: /1 reference: - title: OBR Economic and Fiscal Outlook November 2025 href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 basic: description: The basic rate of tax on savings income. metadata: @@ -12,11 +14,19 @@ basic: unit: /1 values: 2015-04-06: 0.20 + # Budget 2025 set 22% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.22 + value: 0.24 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ higher: description: The higher rate of tax on savings income. @@ -25,11 +35,19 @@ higher: unit: /1 values: 2015-04-06: 0.40 + # Budget 2025 set 42% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.42 + value: 0.44 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ additional: description: The additional rate of tax on savings income. @@ -38,9 +56,17 @@ additional: unit: /1 values: 2015-04-06: 0.45 + # Budget 2025 set 47% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.47 + value: 0.49 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ 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 dd01419059..e753affdcf 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 @@ -291,10 +291,10 @@ property_rental_income: 5_000 property_income: 5_000 output: - # Property: 5,000 of receipts with no expenses, so (5,000 - 1,000) x 22% - # = 880. - property_income_tax: 880 - income_tax: 2_366 + # Property: 5,000 of receipts with no expenses, so (5,000 - 1,000) x 24% + # = 960. Earnings: (20,000 - 12,570) x 20% = 1,486. + property_income_tax: 960 + income_tax: 2_446 uc_income_tax_on_earnings: 1_486 - name: A tax reduction comes off the tax on earnings first diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml index e7e7daebd7..0b6da00b9f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml @@ -13,8 +13,8 @@ property_allowance_deduction: 0 taxable_property_income: 25_000 adjusted_net_income: 25_000 - # (25,000 - 12,570) * 22% (property basic rate from 2027-28) - income_tax: 2_734.60 + # (25,000 - 12,570) * 24% (property basic rate from 2027-28) + income_tax: 2_983.20 - name: Profit within the allowance, receipts unknown, is fully relieved period: 2027 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 7312c6d5bd..5a7f5ad0fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -94,8 +94,8 @@ members: [person] country: ENGLAND output: - # Property income at the 22% property basic rate: 27,430 x 22% = 6,034.60. - property_income_tax: 6_034.60 + # Property income at the 24% property basic rate: 27,430 x 24% = 6,583.20. + property_income_tax: 6_583.20 dividend_income_tax: 4_528.75 - name: Case 6, the annual allowance charge reads the rate on property income too. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml index b205bb7efd..9b76545f49 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml @@ -26,7 +26,7 @@ # (30,000 - 12,570) x 20% + 14,000 x 20% - 1,600 income_tax: 4_686 -- name: The same landlord in 2027-28 gets relief at the 22% property basic rate +- name: The same landlord in 2027-28 gets relief at the 24% property basic rate period: 2027 absolute_error_margin: 0.01 input: @@ -35,10 +35,10 @@ property_income: 14_000 property_finance_costs: 8_000 output: - # 22% x 8,000 - property_finance_cost_relief: 1_760 - # (30,000 - 12,570) x 20% + 14,000 x 22% - 1,760 - income_tax: 4_806 + # 24% x 8,000 + property_finance_cost_relief: 1_920 + # (30,000 - 12,570) x 20% + 14,000 x 24% - 1,920 + income_tax: 4_926 - name: A Scottish taxpayer gets relief at the basic rate of 20% in 2025-26 period: 2025 @@ -52,7 +52,7 @@ pays_scottish_income_tax: true property_finance_cost_relief: 1_600 -- name: A Scottish taxpayer also gets the 22% property basic rate in 2027-28 +- name: A Scottish taxpayer also gets the 24% property basic rate in 2027-28 period: 2027 absolute_error_margin: 0.01 input: @@ -62,9 +62,9 @@ region: SCOTLAND output: pays_scottish_income_tax: true - # s. 274AA(5) gives no Scottish rate: 22% x 8,000, although the property + # s. 274AA(5) gives no Scottish rate: 24% x 8,000, although the property # profits themselves are taxed at Scottish rates. - property_finance_cost_relief: 1_760 + property_finance_cost_relief: 1_920 - name: Relief is limited to the property profits and the rest carried forward period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml index f7448862b2..c55488b45e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml @@ -63,7 +63,9 @@ income_tax: 0 capital_income: 100 market_income: 100 - isa_cash_interest_flat_rate_charge: 22 + # SI 2026/1018 reg. 15 (new reg. 22A(3)): the savings basic rate, 24% in + # 2027-28, on 100. + isa_cash_interest_flat_rate_charge: 24 - name: Noncash cash interest is an of-which detail of an explicitly supplied ISA total period: 2027 @@ -75,7 +77,8 @@ taxable_savings_interest_income: 0 capital_income: 500 market_income: 500 - isa_cash_interest_flat_rate_charge: 22 + # 24% savings basic rate on the 100 of cash interest. + isa_cash_interest_flat_rate_charge: 24 - name: Tax Credits and total taxable income exclude ISA returns without losing ordinary interest or dividends diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 798953e25f..391dcf83fb 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -49,10 +49,11 @@ savings_allowance: 500 basic_rate_savings_income: 0 higher_rate_savings_income: 500 - savings_income_tax: 210 + # 500 x 44% savings higher rate (2027-28) + savings_income_tax: 220 taxed_dividend_income: 500 dividend_income_tax: 178.75 - income_tax: 7_874.75 + income_tax: 7_884.75 - name: Personal allowance caps savings starter rate before dividends. period: 2027 diff --git a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml index 31a39110fa..991f840442 100644 --- a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml +++ b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml @@ -16,6 +16,10 @@ # - Basic rate: 20% -> 22% (+2pp) # - Higher rate: 40% -> 42% (+2pp) # - Additional rate: 45% -> 47% (+2pp) +# +# Budget 2026 (paragraphs 6.246 and 6.247) adds a further 2pp to the savings +# and property rates from the same date, so current law from 2027-28 is +# 24%, 44% and 49%; the post-reform cases below use those rates. # ============================================================================= # DIVIDEND TAX RATE TESTS @@ -103,7 +107,7 @@ # Additional rate taxpayers get £0 savings allowance, so £2000 at 45% savings_income_tax: 2000 * 0.45 -# Test savings rates AFTER April 2027 (should be +2pp: 22/42/47) +# Test savings rates AFTER April 2027 (+4pp from Budgets 2025 and 2026: 24/44/49) - name: Savings tax at higher rate - 2028 (post-reform) period: 2028 absolute_error_margin: 1 @@ -111,8 +115,8 @@ employment_income: 60000 savings_interest_income: 2000 output: - # £1500 at NEW rate 42% - savings_income_tax: 1500 * 0.42 + # £1500 at NEW rate 44% + savings_income_tax: 1500 * 0.44 - name: Savings tax at additional rate - 2028 (post-reform) period: 2028 @@ -121,8 +125,8 @@ employment_income: 200000 savings_interest_income: 2000 output: - # £2000 at NEW rate 47% - savings_income_tax: 2000 * 0.47 + # £2000 at NEW rate 49% + savings_income_tax: 2000 * 0.49 # ============================================================================= # PROPERTY INCOME TAX RATE TESTS @@ -154,7 +158,7 @@ # has already used up basic rate band property_income_tax: 1000 * 0.40 -# Test property rates AFTER April 2027 (should be +2pp: 22/42/47) +# Test property rates AFTER April 2027 (+4pp from Budgets 2025 and 2026: 24/44/49) - name: Property tax at basic rate - 2028 (post-reform) period: 2028 absolute_error_margin: 1 @@ -163,8 +167,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 22% - property_income_tax: 1000 * 0.22 + # £1000 at NEW rate 24% + property_income_tax: 1000 * 0.24 - name: Property tax at higher rate - 2028 (post-reform) period: 2028 @@ -174,8 +178,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 42% - property_income_tax: 1000 * 0.42 + # £1000 at NEW rate 44% + property_income_tax: 1000 * 0.44 - name: Property tax at additional rate - 2028 (post-reform) period: 2028 @@ -185,8 +189,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 47% - property_income_tax: 1000 * 0.47 + # £1000 at NEW rate 49% + property_income_tax: 1000 * 0.49 # ============================================================================= # INTEGRATION TESTS - verify income_tax includes source-specific taxes @@ -202,11 +206,11 @@ property_rental_income: 2000 property_income: 2000 output: - # Property allowance is £1000, so £1000 taxable at 22% - property_income_tax: 220 + # Property allowance is £1000, so £1000 taxable at 24% + property_income_tax: 240 # Total income_tax should include property_income_tax - # Employment: £0 (covered by PA), Property: £220 - income_tax: 220 + # Employment: £0 (covered by PA), Property: £240 + income_tax: 240 - name: Property tax flows into income_tax - higher rate - 2028 period: 2028 @@ -216,16 +220,16 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at 42% - property_income_tax: 420 + # £1000 at 44% + property_income_tax: 440 # income_tax = earned_income_tax + savings_income_tax + dividend_income_tax + property_income_tax # Earned income tax on £60000: PA=£12570, taxable=£47430 # Basic rate (20%): £37700 at 20% = £7540 # Higher rate (40%): £9730 at 40% = £3892 # Total earned: £11432 - # Property: £420 - # Total: £11852 - income_tax: 11852 + # Property: £440 + # Total: £11872 + income_tax: 11872 - name: Savings tax flows into income_tax - higher rate - 2028 period: 2028 @@ -234,9 +238,9 @@ employment_income: 60000 savings_interest_income: 2000 output: - # £1500 at 42% (after £500 savings allowance) - savings_income_tax: 630 + # £1500 at 44% (after £500 savings allowance) + savings_income_tax: 660 # Earned: £11432 (same as above) - # Savings: £630 - # Total: £12062 - income_tax: 12062 + # Savings: £660 + # Total: £12092 + income_tax: 12092 diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml new file mode 100644 index 0000000000..9c08e7ed34 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml @@ -0,0 +1,135 @@ +# Budget 2026 savings and property income rates (Table 6.1, items 61 and 62). +# +# Budget 2025 (Finance Act 2026) set the savings and property rates at 22%, +# 42% and 47% from 6 April 2027. Budget 2026 adds a further 2 percentage +# points from the same date (paragraphs 6.246 and 6.247): +# - property: 24%, 44% and 49% across England, Wales and Northern Ireland; +# Scottish taxpayers' property income stays on the Scottish rates; +# - savings: 24%, 44% and 49% across the UK; +# - finance cost relief stays at the property basic rate (policy costings, +# page 74), so it rises to 24%. + +- name: 2026-27 savings rates are unchanged at 40% for a higher-rate taxpayer + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + savings_interest_income: 2_000 + output: + # 500 Personal Savings Allowance, then 1,500 x 40% + savings_income_tax: 600 + +- name: 2027-28 savings basic rate is 24% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + savings_interest_income: 3_000 + output: + # Non-savings income above the starter-rate limit, 1,000 Personal Savings + # Allowance, then 2,000 x 24% + savings_income_tax: 480 + +- name: 2027-28 savings higher rate is 44% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + savings_interest_income: 2_000 + output: + # 500 Personal Savings Allowance, then 1,500 x 44% + savings_income_tax: 660 + +- name: 2027-28 savings additional rate is 49% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 200_000 + savings_interest_income: 2_000 + output: + # No Personal Savings Allowance for additional-rate taxpayers: 2,000 x 49% + savings_income_tax: 980 + +- name: 2027-28 savings rates apply to Scottish taxpayers too + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + savings_interest_income: 2_000 + region: SCOTLAND + output: + pays_scottish_income_tax: true + # Savings bands follow the UK thresholds: 1,000 Personal Savings + # Allowance, then 1,000 x 24% + savings_income_tax: 240 + +- name: 2027-28 property basic rate is 24% in England + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: LONDON + output: + # (5,000 - 1,000 property allowance) x 24% + property_income_tax: 960 + +- name: 2027-28 property basic rate is 24% in Wales + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: WALES + output: + property_income_tax: 960 + +- name: 2027-28 property basic rate is 24% in Northern Ireland + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: NORTHERN_IRELAND + output: + property_income_tax: 960 + +- name: 2027-28 property higher and additional rates are 44% and 49% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 200_000 + property_rental_income: 2_000 + property_income: 2_000 + output: + # Earnings already fill the additional-rate band: 1,000 x 49% + property_income_tax: 490 + +- name: 2027-28 property income straddling the higher-rate threshold + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 45_000 + property_income: 10_000 + output: + # Earnings use 32,430 of the 37,700 basic-rate band, leaving 5,270. + # 10,000 profit with receipts unknown: no property allowance on top. + # 5,270 x 24% + 4,730 x 44% = 1,264.80 + 2,081.20 + property_income_tax: 3_346 + +- name: 2027-28 finance cost relief rises with the property basic rate + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 20_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + # 24% x 8,000 + property_finance_cost_relief: 1_920 + # (30,000 - 12,570) x 20% + 14,000 x 24% - 1,920 + income_tax: 4_926 diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py new file mode 100644 index 0000000000..e0397937fd --- /dev/null +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -0,0 +1,159 @@ +"""Property tests for the Budget 2026 savings and property income rates. + +Budget 2026 adds 2 percentage points to the savings and property rates from +6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% +and 47%. Each test compares current law with the Budget 2025 rates for the +same generated people, and checks identities that hold for every input: + +- rUK property tax rises by exactly 2% of taxable property income, since every + pound of it sits in one of the three rate bands; +- Scottish taxpayers' property tax does not change, because their property + income stays on the Scottish rates; +- savings tax rises by exactly 2% of the savings income in the basic, higher + and additional bands, in every UK country; +- finance cost relief, which follows the property basic rate, rises by at + most 2% of the costs relieved, and never falls. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +RISE = 0.02 +REGIONS = ["LONDON", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +CASES = 24 +BUDGET_2025_RATES = { + f"gov.hmrc.income_tax.rates.{source}.{band}": {str(YEAR): rate} + for source in ("savings", "property") + for band, rate in (("basic", 0.22), ("higher", 0.42), ("additional", 0.47)) +} + +amount = st.one_of( + st.floats(min_value=0, max_value=60_000), + st.floats(min_value=0, max_value=300_000), + st.sampled_from([0.0, 1_000.0, 12_570.0, 37_700.0, 50_270.0, 125_140.0]), +) +case = st.tuples( + amount, # employment income + amount, # property profit + st.floats(min_value=0, max_value=1), # finance costs as a share of profit + st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + + +def situation(): + return { + "people": { + f"person_{i}": {"age": {YEAR: 45}, "employment_income": {YEAR: 0}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=BUDGET_2025_RATES + ) + + +VARIABLES = [ + "pays_scottish_income_tax", + "taxable_property_income_after_allowances", + "property_income_tax", + "savings_income_tax", + "basic_rate_savings_income", + "higher_rate_savings_income", + "add_rate_savings_income", + "property_finance_costs_relieved", + "property_finance_cost_relief", + "income_tax", +] + + +def run(sims, drawn): + employment, profit, cost_share, savings = map(np.array, zip(*drawn)) + results = [] + for sim in sims: + sim.set_input("employment_income", YEAR, employment) + sim.set_input("property_income", YEAR, profit) + sim.set_input("property_finance_costs", YEAR, profit * cost_share) + sim.set_input("savings_interest_income", YEAR, savings) + sim.reset_calculations() + results.append({v: sim.calculate(v, YEAR).astype(float) for v in VARIABLES}) + return results + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): + law, before = run(sims, drawn) + ruk = ~law["pays_scottish_income_tax"].astype(bool) + rise = law["property_income_tax"] - before["property_income_tax"] + expected = RISE * law["taxable_property_income_after_allowances"] + tol = tolerance(law["property_income_tax"], expected) + assert (np.abs(rise - expected)[ruk] <= tol[ruk]).all() + + +@SETTINGS +@given(cases) +def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): + law, before = run(sims, drawn) + scottish = law["pays_scottish_income_tax"].astype(bool) + rise = law["property_income_tax"] - before["property_income_tax"] + tol = tolerance(law["property_income_tax"]) + assert (np.abs(rise)[scottish] <= tol[scottish]).all() + + +@SETTINGS +@given(cases) +def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): + law, before = run(sims, drawn) + banded = ( + law["basic_rate_savings_income"] + + law["higher_rate_savings_income"] + + law["add_rate_savings_income"] + ) + rise = law["savings_income_tax"] - before["savings_income_tax"] + tol = tolerance(law["savings_income_tax"], banded) + assert (np.abs(rise - RISE * banded) <= tol).all() + + +@SETTINGS +@given(cases) +def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): + law, before = run(sims, drawn) + rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] + cap = RISE * law["property_finance_costs_relieved"] + tol = tolerance(law["property_finance_cost_relief"], cap) + assert (rise >= -tol).all() + assert (rise <= cap + tol).all() diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py index 0e9aeb56ba..1181eae828 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py @@ -32,6 +32,10 @@ class property_income_tax(Variable): title="Finance Act 2026, s. 7 (property income rates)", href="https://www.legislation.gov.uk/ukpga/2026/11/section/7/enacted", ), + dict( + title="Budget 2026, paragraph 6.246 (property rates of 24%, 44% and 49%)", + href="https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142", + ), ] unit = GBP @@ -77,8 +81,10 @@ def formula_2027(person, period, parameters): apply to Scotland). For rUK taxpayers, property income is taxed using property-specific - rates. From April 2027, these are 2pp higher than standard UK rates - (22%, 42%, 47% vs 20%, 40%, 45%). + rates. From April 2027, these are 4pp higher than standard UK rates + (24%, 44%, 49% vs 20%, 40%, 45%): 2pp from Budget 2025 and a further + 2pp from Budget 2026 (paragraph 6.246), which applies them across + England, Wales and Northern Ireland. Property income is stacked on top of other earned income for determining which tax band it falls into. From 8ee81b383ef59464e6f4547fea64e87c127d126a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 10:53:27 -0400 Subject: [PATCH 03/29] Keep the 5p fuel duty cut to 1 July 2027 (Budget 2026) Budget 2026 (paragraph 6.211; policy costings page 13) extends the 5p cut from 1 January 2027 to 1 July 2027. On that date rates rise by 5p and are uprated by RPI, then by RPI each 1 April from 2028. Other road fuels keep a proportionate cut to the same date. - petrol_and_diesel.yaml: 52.95p to 30 June 2027; the January and March 2027 steps are gone. The generator now uprates the 57.95p pre-cut rate by 2026 RPI on 1 July 2027, then each 1 April from 2028. - lpg.yaml, natural_gas.yaml: full rates from 1 July 2027. - Tests and the excise duty engineering note follow. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ab26-fuel-duty.changed.md | 1 + docs/engineering/excise-duties.md | 50 ++++++----- .../fuel_duty/calculate_fuel_duty_rates.py | 51 +++++++----- .../parameters/gov/hmrc/fuel_duty/lpg.yaml | 7 +- .../gov/hmrc/fuel_duty/natural_gas.yaml | 7 +- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 83 +++++++++---------- .../fuel_duty/petrol_and_diesel_schedule.yaml | 23 +++-- .../gov/hmrc/fuel_duty/road_fuel_gases.yaml | 25 ++++-- .../tests/test_fuel_duty_schedule.py | 63 ++++++++------ 9 files changed, 173 insertions(+), 137 deletions(-) create mode 100644 changelog.d/ab26-fuel-duty.changed.md diff --git a/changelog.d/ab26-fuel-duty.changed.md b/changelog.d/ab26-fuel-duty.changed.md new file mode 100644 index 0000000000..c39e67a335 --- /dev/null +++ b/changelog.d/ab26-fuel-duty.changed.md @@ -0,0 +1 @@ +Keep the 5p fuel duty cut to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel return to 57.95p uprated by 2026 RPI growth, then rise by RPI each 1 April from 2028; LPG and natural gas return to their full rates. diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 2e353c04a9..b65fe28607 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -18,11 +18,11 @@ parameter history starts on 1 August 2023; the preceding alcohol regime and a complete 2023/24 calculation are outside this implementation. Tobacco rates start on 22 November 2023. The new alcohol, tobacco and car formulas return their default zero before 2024; that is an unmodelled period, not a statutory exemption. -Gas duty covers 2022/23 onwards and includes the 1 January 2027 and 1 March 2027 -increases. SI 2026/555 removed the September 2026 step announced at Budget 2025 -and moved the December 2026 step to 1 January 2027. Rates after the last dated -entry stay constant; the gas parameters do not forecast future inflation -increases. +Gas duty covers 2022/23 onwards. SI 2026/555 removed the September 2026 step +announced at Budget 2025 and moved the December 2026 step to 1 January 2027. +Budget 2026 then kept the proportionate cut to 1 July 2027, when the gas rates +return to their full levels. Rates after the last dated entry stay constant; +the gas parameters do not forecast future inflation increases. For alcohol and tobacco, a model year such as `2026` represents 6 April 2026 to 5 April 2027. Quantities and cigarette retail prices are constant across that @@ -154,32 +154,36 @@ matching the legislation. No universal litre-to-kilogram density is assumed. The existing `fuel_duty` output includes these charges. Rural fuel duty relief continues to apply only to petrol and diesel. -From 1 March 2027 onwards, the gas parameters hold LPG at £0.3161/kg and natural -gas at £0.2470/kg. This implementation stops at the last numerical rates in the -HMRC 2026-to-2027 schedule and does not extrapolate gas rates using an inflation -forecast. This is a modelling limitation: HMRC states that RPI uprating resumes -from 2027/28. The petrol and diesel rate includes forecast RPI uprating each -1 April from 2027, so its rates rise while the gas rates stay flat. Later-year gas duty is therefore lower than it would be -under the same uprating assumption. Analyses beyond March 2027 that require -consistent fuel-duty forecasts must supply an uprated gas-rate path explicitly. +From 1 July 2027 onwards, the gas parameters hold LPG at £0.3161/kg and natural +gas at £0.2470/kg, the full rates before the 2022 cut. This implementation does +not extrapolate gas rates using an inflation forecast. This is a modelling +limitation: Budget 2026's costing says fuel duty rates are uprated by RPI from +1 July 2027. The petrol and diesel rate includes that forecast uprating, so its +rates rise while the gas rates stay flat. Later-year gas duty is therefore lower +than it would be under the same uprating assumption. Analyses beyond June 2027 +that require consistent fuel-duty forecasts must supply an uprated gas-rate path +explicitly. Sources: [HMRC fuel duty rates 2026 to 2027](https://www.gov.uk/government/publications/fuel-duty-rates-for-2026-to-2027/fuel-duty-rates-2026-to-2027) (RPI uprating from 2027/28), [HMRC amended fuel duty rates 2026 to 2027](https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027) -and [SI 2026/164 as amended by SI 2026/555](https://www.legislation.gov.uk/uksi/2026/164). +[SI 2026/164 as amended by SI 2026/555](https://www.legislation.gov.uk/uksi/2026/164) +and [Budget 2026 policy costings, page 13](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13). ## Petrol and diesel rate The main rate on unleaded petrol and diesel is keyed to each statutory change: -52.95p a litre from 23 March 2022, continued by annual orders to 31 December 2026; -55.95p from 1 January 2027 (SI 2026/164 as amended by SI 2026/555); and the Hydrocarbon Oil -Duties Act 1979 rate of 57.95p from 1 March 2027. Budget 2025 states that rates -will be uprated by RPI from April 2027, and HM Treasury's Budget 2025 costing -dates this from 1 April 2027. That uprating is policy, not law: HMRC's amended -note of 22 May 2026 says final rates will be confirmed at Budget 2026. No -official source names the RPI measure. The model applies the previous calendar -year's RPI growth from the OBR forecast in -`gov.economic_assumptions.yoy_growth.obr.rpi` on each 1 April, rounded to the +52.95p a litre from 23 March 2022, continued by annual orders to 31 December 2026 +(SI 2026/164 as amended by SI 2026/555). Those orders then stepped to 55.95p on +1 January 2027 and to the Hydrocarbon Oil Duties Act 1979 rate of 57.95p on +1 March 2027. Budget 2026 (paragraph 6.211) cancelled both steps and kept the +cut to 1 July 2027. Its costing (page 13) says: "On 1 July 2027, fuel duty rates +will increase by 5ppl and will be uprated in line with the Retail Prices Index +(RPI). From 1 April 2028, fuel duty rates will be uprated annually in line with +RPI." That is policy, not law. No official source names the RPI measure. The +model takes the 57.95p pre-cut rate and applies 2026 RPI growth on 1 July 2027, +then the previous calendar year's RPI growth on each 1 April from 2028, from the +OBR forecast in `gov.economic_assumptions.yoy_growth.obr.rpi`, rounded to the nearest hundredth of a penny. The steps to 1 April 2031 are written out by `calculate_fuel_duty_rates.py`, and a test fails when they fall out of date with the RPI forecast. Later steps come from the parameter's uprating metadata. diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index f0ec7ffa27..8e34c85736 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -1,11 +1,14 @@ """ Generates the forecast RPI uprating steps in petrol_and_diesel.yaml. -The statutory schedule ends with the Hydrocarbon Oil Duties Act 1979 rate of -57.95p from 1 March 2027. Budget 2025 states that rates will then be uprated -by RPI from April 2027. This script applies each previous calendar year's RPI -growth from economic_assumptions/yoy_growth.yaml on 1 April (the date in HM -Treasury's Budget 2025 costing), rounding to the nearest hundredth of a penny, and writes the results between the BEGIN and END +Budget 2026 keeps the 5p cut to 1 July 2027. Its policy costing (page 13) +says that "On 1 July 2027, fuel duty rates will increase by 5ppl and will be +uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel +duty rates will be uprated annually in line with RPI." This script takes the +57.95p pre-cut rate (Finance Act 2011 s. 19), applies 2026 RPI growth on +1 July 2027, then each previous calendar year's RPI growth on 1 April from +2028, using economic_assumptions/yoy_growth.yaml. It rounds to the nearest +hundredth of a penny and writes the results between the BEGIN and END markers in petrol_and_diesel.yaml. Run it with --update whenever the RPI forecast changes; test_fuel_duty_schedule.py fails until you do. @@ -24,15 +27,19 @@ PARAMETER_FILE = FUEL_DUTY_DIR / "petrol_and_diesel.yaml" YOY_GROWTH_FILE = FUEL_DUTY_DIR.parents[1] / "economic_assumptions" / "yoy_growth.yaml" -# The last statutory rate, and the first and last 1 April steps written out. -# Later steps come from the parameter's uprating metadata. -STATUTORY_DATE = "2027-03-01" -FIRST_UPRATING_YEAR = 2027 +# The pre-cut rate the first step uprates, the date the cut ends, and the +# first and last 1 April steps written out. Later steps come from the +# parameter's uprating metadata. +BASE_DATE = "2011-03-23" +CUT_END_DATE = "2027-07-01" +FIRST_APRIL_YEAR = 2028 LAST_UPRATING_YEAR = 2031 BEGIN = " # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py\n" END = " # END RPI forecast generated by calculate_fuel_duty_rates.py\n" PENNY_HUNDREDTH = Decimal("0.0001") +BUDGET_2026 = "https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138" +COSTING_2026 = "https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13" def rpi_series() -> dict: @@ -46,24 +53,28 @@ def rpi_growth_by_year() -> dict: return {int(str(date)[:4]): Decimal(str(value)) for date, value in values.items()} -def statutory_rate() -> Decimal: +def base_rate() -> Decimal: with open(PARAMETER_FILE) as f: values = yaml.safe_load(f)["values"] - entry = {str(date): value for date, value in values.items()}[STATUTORY_DATE] + entry = {str(date): value for date, value in values.items()}[BASE_DATE] return Decimal(str(entry["value"] if isinstance(entry, dict) else entry)) def forecast_steps() -> list: - """(date, rate, previous rate, RPI growth) for each forecast 1 April step.""" + """(date, rate, previous rate, RPI growth) for each forecast step.""" growth = rpi_growth_by_year() - rate = statutory_rate() + dates = [CUT_END_DATE] + [ + f"{year}-04-01" for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1) + ] + rate = base_rate() steps = [] - for year in range(FIRST_UPRATING_YEAR, LAST_UPRATING_YEAR + 1): + for date in dates: + year = int(date[:4]) previous = rate rate = (previous * (1 + growth[year - 1])).quantize( PENNY_HUNDREDTH, rounding=ROUND_HALF_UP ) - steps.append((f"{year}-04-01", rate, previous, growth[year - 1])) + steps.append((date, rate, previous, growth[year - 1])) return steps @@ -79,10 +90,10 @@ def render(steps: list) -> str: f" note: Forecast. {previous} uprated by {int(date[:4]) - 1} RPI " f"growth of {growth * 100:.1f}%.", " reference:", - " - title: Budget 2025 (fuel duty uprated by RPI from April 2027)", - " href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html", - " - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027)", - " href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14", + " - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027)", + f" href: {BUDGET_2026}", + " - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028)", + f" href: {COSTING_2026}", f" - title: {forecast['title']} (RPI forecast)", f" href: {forecast['href']}", ] @@ -106,7 +117,7 @@ def main(): ) args = parser.parse_args() steps = forecast_steps() - print(f"{STATUTORY_DATE}: {statutory_rate()} (statutory)") + print(f"{BASE_DATE}: {base_rate()} (pre-cut rate)") for date, rate, previous, growth in steps: print(f"{date}: {rate} ({previous} x (1 + {growth}))") if args.update: diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index ff8911779c..d135be7f05 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -2,8 +2,7 @@ description: The UK levies road fuel duty on LPG at this rate per kilogram. values: '2021-01-01': 0.3161 '2022-03-23': 0.2888 - '2027-01-01': 0.3052 - '2027-03-01': 0.3161 + '2027-07-01': 0.3161 metadata: unit: currency-GBP period: year @@ -11,8 +10,10 @@ metadata: reference: - title: Hydrocarbon Oil Duties Act 1979, section 8 href: https://www.legislation.gov.uk/ukpga/1979/5/section/8 - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) + - title: SI 2026/164, as amended by SI 2026/555 (cut continued to 31 December 2026) href: https://www.legislation.gov.uk/uksi/2026/164 + - title: Budget 2026, paragraph 6.211, and policy costings page 13 (proportionate cut to other fuels to 1 July 2027; not yet law) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index e36135665e..54954c6f14 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -2,8 +2,7 @@ description: The UK levies road fuel duty on natural gas at this rate per kilogr values: '2021-01-01': 0.247 '2022-03-23': 0.2257 - '2027-01-01': 0.2385 - '2027-03-01': 0.247 + '2027-07-01': 0.247 metadata: unit: currency-GBP period: year @@ -11,8 +10,10 @@ metadata: reference: - title: Hydrocarbon Oil Duties Act 1979, section 8 href: https://www.legislation.gov.uk/ukpga/1979/5/section/8 - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) + - title: SI 2026/164, as amended by SI 2026/555 (cut continued to 31 December 2026) href: https://www.legislation.gov.uk/uksi/2026/164 + - title: Budget 2026, paragraph 6.211, and policy costings page 13 (proportionate cut to other fuels to 1 July 2027; not yet law) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index cf7d2a640f..0092c4d11f 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -68,7 +68,8 @@ values: href: https://www.legislation.gov.uk/uksi/2025/228/made # Budget 2025 scheduled steps on 1 September 2026, 1 December 2026 and # 1 March 2027. SI 2026/555 removed the September step and moved the - # December step to 1 January 2027. + # December step to 1 January 2027. Budget 2026 then cancelled the January + # and March 2027 steps, keeping the 5p cut to 1 July 2027. 2026-03-23: value: 0.5295 metadata: @@ -80,39 +81,37 @@ values: - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 2027-01-01: - value: 0.5595 - metadata: - reference: - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) - href: https://www.legislation.gov.uk/uksi/2026/164 - - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) - href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 - 2027-03-01: - value: 0.5795 + value: 0.5295 metadata: - reference: - - title: Hydrocarbon Oil Duties Act 1979, section 6(1A) (no reduction order applies after 28 February 2027) - href: https://www.legislation.gov.uk/ukpga/1979/5/section/6 - - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) - href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 - # Forecast from April 2027. Budget 2025 states that rates will be uprated by - # RPI from April 2027, and HM Treasury's costing dates this from 1 April - # 2027. That is policy, not law: HMRC's amended note of 22 May 2026 says - # final rates will be confirmed at Budget 2026. No official source names the - # RPI measure. Each 1 April applies the previous calendar year's RPI growth - # from gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest + note: Budget 2026 policy, not yet law. The 5p cut runs to 1 July 2027 in place of SI 2026/164's steps to 55.95p on 1 January 2027 and 57.95p on 1 March 2027. + reference: + - title: Budget 2026, paragraph 6.211 (cut extended to 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026 policy costings, page 13 + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: Budget 2026, Table 6.1, item 3 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + # Forecast from 1 July 2027. Budget 2026's costing: "On 1 July 2027, fuel + # duty rates will increase by 5ppl and will be uprated in line with the + # Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be + # uprated annually in line with RPI." That is policy, not law. No official + # source names the RPI measure. The 1 July 2027 step takes the 57.95p + # pre-cut rate (Finance Act 2011 s. 19) and applies 2026 RPI growth, the + # increase deferred from April 2027; each 1 April from 2028 applies the + # previous calendar year's RPI growth from + # gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest # hundredth of a penny. After the last entry, the uprating metadata below # continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py - 2027-04-01: + 2027-07-01: value: 0.5975 metadata: note: Forecast. 0.5795 uprated by 2026 RPI growth of 3.1%. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2028-04-01: @@ -120,10 +119,10 @@ values: metadata: note: Forecast. 0.5975 uprated by 2027 RPI growth of 3.0%. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2029-04-01: @@ -131,10 +130,10 @@ values: metadata: note: Forecast. 0.6154 uprated by 2028 RPI growth of 2.8%. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2030-04-01: @@ -142,10 +141,10 @@ values: metadata: note: Forecast. 0.6326 uprated by 2029 RPI growth of 2.9%. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2031-04-01: @@ -153,10 +152,10 @@ values: metadata: note: Forecast. 0.6509 uprated by 2030 RPI growth of 2.3%. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ # END RPI forecast generated by calculate_fuel_duty_rates.py diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml index 4dfe920b38..3bdf69819a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml @@ -1,13 +1,13 @@ # Fuel duty on 1,000 litres in each model year. A model year runs from # 6 April to 5 April, and the dated rates are day-weighted across it: # 2025-26: 365 days at 52.95p. -# 2026-27: 270 days at 52.95p (6 Apr to 31 Dec 2026), 59 at 55.95p -# (Jan to Feb 2027), 31 at 57.95p (Mar 2027) and 5 at 59.75p. -# 2027-28 (366 days): 361 at 59.75p and 5 at 61.54p. +# 2026-27: 365 days at 52.95p (Budget 2026 keeps the cut to 1 July 2027). +# 2027-28 (366 days): 86 at 52.95p (6 Apr to 30 Jun 2027), 275 at 59.75p +# (1 Jul 2027 to 31 Mar 2028) and 5 at 61.54p. # 2028-29: 360 at 61.54p and 5 at 63.26p. # 2029-30: 360 at 63.26p and 5 at 65.09p. # 2030-31: 360 at 65.09p and 5 at 66.59p. -# Rates from 1 April 2027 are the RPI forecast in petrol_and_diesel.yaml. +# Rates from 1 July 2027 are the RPI forecast in petrol_and_diesel.yaml. - name: Fuel duty for 2025-26 is 52.95p a litre all year period: 2025 absolute_error_margin: 0.01 @@ -16,24 +16,23 @@ diesel_litres: 0 output: fuel_duty: 529.5 -- name: Fuel duty for 2026-27 carries the January and March 2027 increases +- name: Fuel duty for 2026-27 stays at 52.95p a litre all year period: 2026 absolute_error_margin: 0.01 input: petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.5295 * 270 + 0.5595 * 59 + 0.5795 * 31 + 0.5975 * 5) / 365 - fuel_duty: 539.5273972602739 -- name: Fuel duty for 2027-28 is the April 2027 RPI-uprated rate + fuel_duty: 529.5 +- name: Fuel duty for 2027-28 carries the 1 July 2027 rise period: 2027 absolute_error_margin: 0.01 input: petrol_litres: 600 diesel_litres: 400 output: - # 1000 * (0.5975 * 361 + 0.6154 * 5) / 366 - fuel_duty: 597.7445355191257 + # 1000 * (0.5295 * 86 + 0.5975 * 275 + 0.6154 * 5) / 366 + fuel_duty: 581.766393442623 - name: Fuel duty for 2028-29 period: 2028 absolute_error_margin: 0.01 @@ -69,5 +68,5 @@ diesel_litres: 0 in_rural_fuel_duty_relief_area: true output: - # 597.7445355191257 - 1000 * 0.05 - fuel_duty: 547.7445355191257 + # 581.766393442623 - 1000 * 0.05 + fuel_duty: 531.766393442623 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml index 6684b8b9b1..10ffa1b2fe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml @@ -1,6 +1,6 @@ -# Gas quantities are kilograms. FY2026 weights are 270/59/36 days: the -# 5p-equivalent cut runs to 31 Dec 2026, then rates rise on 1 Jan and -# 1 Mar 2027 (SI 2026/164 as amended by SI 2026/555). +# Gas quantities are kilograms. Budget 2026 keeps the 5p-equivalent cut to +# 1 July 2027 (paragraph 6.211; policy costings page 13), so FY2026 is all at +# the cut rates and FY2027 weights are 86/280 days. # Rural relief only reduces the petrol/diesel component. - name: No road fuel gas consumption period: 2026 @@ -33,7 +33,7 @@ in_rural_fuel_duty_relief_area: true output: fuel_duty: 994 -- name: Fiscal 2026 includes the January and March 2027 gas rate increases +- name: Fiscal 2026 gas rates stay at the cut rates all year period: 2026 absolute_error_margin: 0.01 input: @@ -42,6 +42,17 @@ petrol_litres: 0 diesel_litres: 0 output: - # 1000 * (0.2888 * 270 + 0.3052 * 59 + 0.3161 * 36) / 365 - # + 1000 * (0.2257 * 270 + 0.2385 * 59 + 0.2470 * 36) / 365 - fuel_duty: 524.0134246575342 + # 1000 * (0.2888 + 0.2257) + fuel_duty: 514.5 +- name: Fiscal 2027 gas rates return to the full rates on 1 July 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + lpg_kg: 1000 + natural_gas_kg: 1000 + petrol_litres: 0 + diesel_litres: 0 + output: + # 1000 * (0.2888 * 86 + 0.3161 * 280) / 366 + # + 1000 * (0.2257 * 86 + 0.2470 * 280) / 366 + fuel_duty: 551.6803278688524 diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index 743381e783..0062f88e96 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -2,8 +2,9 @@ The petrol and diesel fuel duty rate follows its dated schedule. Each rate is keyed to the date it takes effect, and `fiscal_year_blend` -day-weights the schedule across each model year (6 April to 5 April). Rates -from 1 April 2027 are an RPI forecast: Budget 2025 policy, not yet law. +day-weights the schedule across each model year (6 April to 5 April). +Budget 2026 keeps the 5p cut to 1 July 2027; rates from then are an RPI +forecast. Both are Budget 2026 policy, not yet law. """ import datetime @@ -21,7 +22,10 @@ from policyengine_uk.system import system PATH = "gov.hmrc.fuel_duty.petrol_and_diesel" -FORECAST_YEARS = range(2027, 2032) +# The 1 July 2027 step, then each 1 April. Each applies the previous calendar +# year's RPI growth. +FORECAST_DATES = ["2027-07-01"] + [f"{year}-04-01" for year in range(2028, 2032)] +LAST_FORECAST_YEAR = 2031 CONVERTED_YEARS = range(2015, 2041) @@ -57,29 +61,29 @@ def model_year_days(year: int) -> list: ("2022-03-22", 0.5795), ("2022-03-23", 0.5295), # SI 2026/555 removed the 1 September 2026 step and moved the - # 1 December 2026 step to 1 January 2027. + # 1 December 2026 step to 1 January 2027; Budget 2026 cancelled the + # January and March 2027 steps and kept the cut to 1 July 2027. ("2026-09-01", 0.5295), ("2026-12-01", 0.5295), ("2026-12-31", 0.5295), - ("2027-01-01", 0.5595), - ("2027-02-28", 0.5595), - ("2027-03-01", 0.5795), - ("2027-03-31", 0.5795), + ("2027-01-01", 0.5295), + ("2027-03-01", 0.5295), + ("2027-04-01", 0.5295), + ("2027-06-30", 0.5295), ], ) -def test_statutory_rate_on_each_date(date, rate): +def test_scheduled_rate_on_each_date(date, rate): schedule = raw_parameters().gov.hmrc.fuel_duty.petrol_and_diesel assert schedule(date) == pytest.approx(rate, abs=1e-12) -# Model year Y runs from 6 April Y to 5 April Y+1. Rates from 1 April 2027: -# 59.75p, then 61.54p (2028), 63.26p (2029), 65.09p (2030), 66.59p (2031). +# Model year Y runs from 6 April Y to 5 April Y+1. Rates: 59.75p from 1 July +# 2027, then 61.54p (2028), 63.26p (2029), 65.09p (2030), 66.59p (2031). HAND_COMPUTED = { 2025: 0.5295, - # 6 Apr to 31 Dec 2026, Jan to Feb 2027, Mar 2027, 1 to 5 Apr 2027. - 2026: (0.5295 * 270 + 0.5595 * 59 + 0.5795 * 31 + 0.5975 * 5) / 365, - # 2028 is a leap year: 361 days to 31 March, then 5 days. - 2027: (0.5975 * 361 + 0.6154 * 5) / 366, + 2026: 0.5295, + # 6 Apr to 30 Jun 2027, then to 31 Mar 2028 (a leap year), then 5 days. + 2027: (0.5295 * 86 + 0.5975 * 275 + 0.6154 * 5) / 366, 2028: (0.6154 * 360 + 0.6326 * 5) / 365, 2029: (0.6326 * 360 + 0.6509 * 5) / 365, 2030: (0.6509 * 360 + 0.6659 * 5) / 365, @@ -92,7 +96,7 @@ def test_model_year_rate_is_hand_computed_day_weighted_average(year, rate): def test_forecast_steps_use_current_rpi_forecast(): - """The written 1 April steps match the RPI series they claim to use. + """The written steps match the RPI series they claim to use. Fails when the RPI forecast in yoy_growth.yaml changes. Regenerate with `uv run python policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py --update`. @@ -100,21 +104,25 @@ def test_forecast_steps_use_current_rpi_forecast(): parameters = raw_parameters() rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi schedule = parameters.gov.hmrc.fuel_duty.petrol_and_diesel + # The 57.95p pre-cut rate (Finance Act 2011 s. 19). rate = Decimal("0.5795") - for year in FORECAST_YEARS: - growth = Decimal(str(rpi(f"{year - 1}-01-01"))) + for date in FORECAST_DATES: + day_before = ( + datetime.date.fromisoformat(date) - datetime.timedelta(days=1) + ).isoformat() + growth = Decimal(str(rpi(f"{int(date[:4]) - 1}-01-01"))) rate = (rate * (1 + growth)).quantize(Decimal("0.0001"), ROUND_HALF_UP) - assert schedule(f"{year}-04-01") == pytest.approx(float(rate), abs=1e-12), ( - f"{year}-04-01 is stale against the RPI forecast; run " + assert schedule(date) == pytest.approx(float(rate), abs=1e-12), ( + f"{date} is stale against the RPI forecast; run " "calculate_fuel_duty_rates.py --update" ) - assert schedule(f"{year}-03-31") != schedule(f"{year}-04-01") + assert schedule(day_before) != schedule(date) def test_uprating_continues_each_april_after_the_written_steps(): schedule = dated_schedule() rpi = system.parameters.baseline.gov.economic_assumptions.yoy_growth.obr.rpi - for year in range(max(FORECAST_YEARS) + 1, 2041): + for year in range(LAST_FORECAST_YEAR + 1, 2041): previous = schedule(f"{year - 1}-04-01") assert schedule(f"{year}-03-31") == previous expected = previous * (1 + rpi(f"{year - 1}-01-01")) @@ -162,18 +170,19 @@ def fuel_duty_simulation(parameter_changes=None): return Microsimulation(situation=situation, scenario=scenario) -def test_holding_55_95p_for_2027_removes_the_full_2027_28_rate_rise(): - """Regression: calendar-year averaging cut this effect to 3.30p a litre. +def test_holding_52_95p_for_2027_removes_the_full_2027_28_rate_rise(): + """Regression: calendar-year averaging diluted annual overrides. - The 2027-28 baseline is 59.77p, so holding 55.95p saves 3.82p a litre. + The 2027-28 baseline averages 58.18p, so holding 52.95p for the whole + model year saves 5.23p a litre. """ baseline = fuel_duty_simulation() - reformed = fuel_duty_simulation({PATH: {"2027": 0.5595}}) + reformed = fuel_duty_simulation({PATH: {"2027": 0.5295}}) saving = ( baseline.calculate("fuel_duty", 2027).values[0] - reformed.calculate("fuel_duty", 2027).values[0] ) - assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5595)) + assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5295)) @settings(max_examples=8, deadline=None) From 66d028152ffa988c79e7033f1898aace467568a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 11:13:37 -0400 Subject: [PATCH 04/29] Extend the zero rate of VAT on domestic electricity to 30 June 2027 (Budget 2026) Budget 2026 (paragraph 6.210; policy costings page 12) extends the temporary 0% rate from 31 March 2027 to 1 July 2027, and expects it to apply UK-wide from April 2027 (section 4.3: "Extended VAT relief is expected to apply in Northern Ireland from April 2027"). - great_britain.yaml: 5% returns on 1 July 2027, not 1 April. - northern_ireland.yaml: 0% from 1 April to 30 June 2027. - Tests follow the new day weights: FY 2026-27 GB 178/365 at 5%, NI 360/365; FY 2027-28 280/366 at 5% in every nation. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ab26-electricity-vat.changed.md | 1 + .../great_britain.yaml | 18 +++- .../northern_ireland.yaml | 20 +++- .../gov/hmrc/vat/domestic_energy_vat.yaml | 40 ++++++-- .../tests/test_domestic_electricity_vat.py | 94 +++++++++++++------ .../tests/test_private_school_vat_receipts.py | 12 ++- .../variables/gov/hmrc/domestic_energy_vat.py | 3 +- 7 files changed, 141 insertions(+), 47 deletions(-) create mode 100644 changelog.d/ab26-electricity-vat.changed.md diff --git a/changelog.d/ab26-electricity-vat.changed.md b/changelog.d/ab26-electricity-vat.changed.md new file mode 100644 index 0000000000..cc02fa798b --- /dev/null +++ b/changelog.d/ab26-electricity-vat.changed.md @@ -0,0 +1 @@ +Extend the temporary zero rate of VAT on domestic electricity to 30 June 2027, and apply it in Northern Ireland from 1 April 2027, as announced at Budget 2026. diff --git a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml index 3a066700c4..bb5da8d649 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml @@ -1,4 +1,4 @@ -description: VAT rate on supplies of domestic electricity in England, Wales and Scotland. It is the reduced rate except while the temporary zero rate applies. +description: VAT rate on supplies of domestic electricity in England, Wales and Scotland. It is the reduced rate except while the temporary zero rate applies, from 1 October 2026 to 30 June 2027. values: 1994-04-01: 0.08 1997-09-01: 0.05 @@ -10,7 +10,18 @@ values: href: https://www.legislation.gov.uk/uksi/2026/987/contents/made - title: HMRC Revenue and Customs Brief 10 (2026) href: https://www.gov.uk/government/publications/revenue-and-customs-brief-10-2026-temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain/temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain - 2027-04-01: 0.05 + # SI 2026/987 ends the zero rate on 31 March 2027. Budget 2026 extends it + # by three months, to 1 July 2027; that is policy, not yet law. + 2027-07-01: + value: 0.05 + metadata: + reference: + - title: Budget 2026, paragraph 6.210 (zero rate extended to 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026 policy costings, page 12 (from 1 October 2026 until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 + - title: Budget 2026, Table 6.1, item 2 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx metadata: unit: /1 period: year @@ -22,5 +33,6 @@ metadata: href: https://www.legislation.gov.uk/uksi/2026/987/contents/made # Liability follows the date the electricity is consumed (Revenue and # Customs Brief 10 (2026)). Day-weighting the fiscal year assumes equal - # daily use: FY 2026-27 has 183 days at 5% and 182 at 0%. + # daily use: FY 2026-27 has 178 days at 5% and 187 at 0%, and FY 2027-28 + # has 86 days at 0% and 280 at 5%. fiscal_year_blend: true diff --git a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml index cbb38988ad..3c864f6a29 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml @@ -1,7 +1,23 @@ -description: VAT rate on supplies of domestic electricity in Northern Ireland. The temporary zero rate from 1 October 2026 does not apply there. +description: VAT rate on supplies of domestic electricity in Northern Ireland. The temporary zero rate from 1 October 2026 does not apply there; Budget 2026 expects the extended zero rate to apply from 1 April to 30 June 2027. values: 1994-04-01: 0.08 1997-09-01: 0.05 + # Budget 2026 expects the extended relief to apply UK-wide from April 2027. + # That is policy, not yet law, and the document says "expected". + 2027-04-01: + value: 0 + metadata: + reference: + - title: Budget 2026, paragraph 6.210 ("expected to apply UK-wide from April 2027") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026, section 4.3 ("Extended VAT relief is expected to apply in Northern Ireland from April 2027") + href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html + 2027-07-01: + value: 0.05 + metadata: + reference: + - title: Budget 2026 policy costings, page 12 (until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 metadata: unit: /1 period: year @@ -11,4 +27,6 @@ metadata: href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/7A - title: HMRC Revenue and Customs Brief 10 (2026) href: https://www.gov.uk/government/publications/revenue-and-customs-brief-10-2026-temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain/temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain + # FY 2026-27 has 360 days at 5% and 5 at 0%; FY 2027-28 has 86 at 0% and + # 280 at 5%. fiscal_year_blend: true diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml index ed9b9da39f..31fec255b9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml @@ -1,7 +1,11 @@ -# Temporary GB zero rate on domestic electricity, 1 October 2026 to -# 31 March 2027 (SI 2026/987). £1,050 of bills carries £50 of VAT at 5%. -# FY 2026-27 has 183 days at 5% and 182 at 0%: 50 * 183 / 365 = 25.07. -- name: GB electricity in FY 2026-27 is zero-rated for 182 of 365 days +# Temporary zero rate on domestic electricity: Great Britain from 1 October +# 2026 (SI 2026/987), extended by Budget 2026 to 1 July 2027 and expected to +# apply in Northern Ireland from 1 April 2027 (paragraph 6.210). £1,050 of +# bills carries £50 of VAT at 5%. +# FY 2026-27: GB 178 days at 5% and 187 at 0%: 50 * 178 / 365 = 24.38; +# NI 360 days at 5% and 5 at 0%: 50 * 360 / 365 = 49.32. +# FY 2027-28: every nation 86 days at 0% and 280 at 5%: 50 * 280 / 366 = 38.25. +- name: GB electricity in FY 2026-27 is zero-rated for 187 of 365 days period: 2026 absolute_error_margin: 0.01 input: @@ -9,9 +13,9 @@ electricity_consumption: 1_050 gas_consumption: 0 output: - domestic_energy_vat: 25.07 + domestic_energy_vat: 24.38 -- name: Northern Ireland electricity stays at 5% +- name: Northern Ireland electricity is zero-rated from 1 April 2027 period: 2026 absolute_error_margin: 0.01 input: @@ -19,7 +23,7 @@ electricity_consumption: 1_050 gas_consumption: 0 output: - domestic_energy_vat: 50 + domestic_energy_vat: 49.32 - name: Gas is not affected by the electricity zero rate period: 2026 @@ -31,9 +35,29 @@ output: domestic_energy_vat: 50 -- name: GB electricity is back at 5% in FY 2027-28 +- name: GB electricity in FY 2027-28 is zero-rated to 30 June 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + region: LONDON + electricity_consumption: 1_050 + gas_consumption: 0 + output: + domestic_energy_vat: 38.25 + +- name: Northern Ireland electricity in FY 2027-28 is zero-rated to 30 June 2027 period: 2027 absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + electricity_consumption: 1_050 + gas_consumption: 0 + output: + domestic_energy_vat: 38.25 + +- name: Electricity is back at 5% in FY 2028-29 + period: 2028 + absolute_error_margin: 0.01 input: region: LONDON electricity_consumption: 1_050 diff --git a/policyengine_uk/tests/test_domestic_electricity_vat.py b/policyengine_uk/tests/test_domestic_electricity_vat.py index bc75f3f469..4177cae13b 100644 --- a/policyengine_uk/tests/test_domestic_electricity_vat.py +++ b/policyengine_uk/tests/test_domestic_electricity_vat.py @@ -2,10 +2,13 @@ Domestic electricity VAT: the temporary GB zero rate (SI 2026/987) and the explicit energy VAT base (#2166). -From 1 October 2026 to 31 March 2027 domestic electricity in England, Wales -and Scotland is zero-rated; Northern Ireland and gas stay at 5%. Model year -2026 runs 6 April 2026 to 5 April 2027, so with equal daily use it has 183 -days at 5% and 182 at 0%. +From 1 October 2026 domestic electricity in England, Wales and Scotland is +zero-rated. Budget 2026 extends the zero rate to 1 July 2027 and expects it to +apply in Northern Ireland from 1 April 2027 (paragraph 6.210). Gas stays at +5%. Model year 2026 runs 6 April 2026 to 5 April 2027, so with equal daily use +Great Britain has 178 days at 5% and 187 at 0%, and Northern Ireland 360 at 5% +and 5 at 0%. Model year 2027 (366 days) has 86 days at 0% and 280 at 5% in +every nation. """ from pathlib import Path @@ -19,7 +22,9 @@ from policyengine_uk.model_api import Scenario from policyengine_uk.system import system -FY2026_SHARE_AT_5_PERCENT = 183 / 365 +FY2026_SHARE_AT_5_PERCENT = 178 / 365 +NI_FY2026_SHARE_AT_5_PERCENT = 360 / 365 +FY2027_SHARE_AT_5_PERCENT = 280 / 366 GB_RATE = "gov.hmrc.vat.domestic_electricity_rate.great_britain" @@ -57,28 +62,39 @@ def calc(variable, year=2026, scenario=None, **kwargs): @pytest.mark.parametrize( - "date, rate", + "date, great_britain, northern_ireland", [ - ("2026-09-30", 0.05), - ("2026-10-01", 0.0), - ("2027-03-31", 0.0), - ("2027-04-01", 0.05), + ("2026-09-30", 0.05, 0.05), + ("2026-10-01", 0.0, 0.05), + ("2027-03-31", 0.0, 0.05), + ("2027-04-01", 0.0, 0.0), + ("2027-06-30", 0.0, 0.0), + ("2027-07-01", 0.05, 0.05), ], ) -def test_gb_rate_changes_on_both_statutory_dates(date, rate): +def test_rates_change_on_each_scheduled_date(date, great_britain, northern_ireland): rates = raw_parameters().gov.hmrc.vat.domestic_electricity_rate - assert rates.great_britain(date) == pytest.approx(rate) - assert rates.northern_ireland(date) == pytest.approx(0.05) + assert rates.great_britain(date) == pytest.approx(great_britain) + assert rates.northern_ireland(date) == pytest.approx(northern_ireland) -def test_fy2026_gb_rate_is_day_weighted(): +def test_model_year_rates_are_day_weighted(): rates = system.parameters.gov.hmrc.vat.domestic_electricity_rate - assert rates.great_britain("2025") == pytest.approx(0.05) + for year in ("2025", "2028"): + assert rates.great_britain(year) == pytest.approx(0.05) + assert rates.northern_ireland(year) == pytest.approx(0.05) assert rates.great_britain("2026") == pytest.approx( 0.05 * FY2026_SHARE_AT_5_PERCENT ) - assert rates.great_britain("2027") == pytest.approx(0.05) - assert rates.northern_ireland("2026") == pytest.approx(0.05) + assert rates.northern_ireland("2026") == pytest.approx( + 0.05 * NI_FY2026_SHARE_AT_5_PERCENT + ) + assert rates.great_britain("2027") == pytest.approx( + 0.05 * FY2027_SHARE_AT_5_PERCENT + ) + assert rates.northern_ireland("2027") == pytest.approx( + 0.05 * FY2027_SHARE_AT_5_PERCENT + ) @pytest.mark.parametrize("region", ["LONDON", "WALES", "SCOTLAND"]) @@ -89,15 +105,24 @@ def test_gb_electricity_vat_in_fy2026(region): ) -def test_northern_ireland_keeps_5_percent(): - assert calc("domestic_energy_vat", region="NORTHERN_IRELAND") == pytest.approx(50) +def test_northern_ireland_is_zero_rated_only_from_april_2027(): + assert calc("domestic_energy_vat", region="NORTHERN_IRELAND") == pytest.approx( + 50 * NI_FY2026_SHARE_AT_5_PERCENT + ) + + +@pytest.mark.parametrize("region", ["LONDON", "SCOTLAND", "NORTHERN_IRELAND"]) +def test_every_nation_is_zero_rated_to_1_july_2027(region): + assert calc("domestic_energy_vat", year=2027, region=region) == pytest.approx( + 50 * FY2027_SHARE_AT_5_PERCENT + ) def test_gas_is_unchanged(): assert calc("domestic_energy_vat", electricity=0, gas=1_050) == pytest.approx(50) -@pytest.mark.parametrize("year", [2025, 2027]) +@pytest.mark.parametrize("year", [2025, 2028]) def test_years_either_side_are_at_5_percent(year): assert calc("domestic_energy_vat", year=year) == pytest.approx(50) @@ -143,27 +168,35 @@ def test_extending_the_zero_rate_to_fy2027(): # Dated reforms are applied on the statutory timeline and day-weighted # into the model year (#2167), so the zero rate must cover all of model # year 2027: 6 April 2027 to 5 April 2028. A calendar-2027 window would - # leave 96 of 366 days at 5%. + # leave 96 of 366 days at 5%. The baseline already zero-rates the first + # 86 days, so the reform removes the other 280: 50 x 280 / 366 = £38.25. reform = Reform.from_dict( {GB_RATE: {"2027-04-06.2028-04-05": 0.0}}, country_id="uk" ) sim = Simulation(situation=household(year=2027), reform=reform) + baseline = 50 * FY2027_SHARE_AT_5_PERCENT assert sim.calculate("domestic_energy_vat", 2027)[0] == 0 - assert sim.calculate("baseline_domestic_energy_vat", 2027)[0] == pytest.approx(50) - assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-50) + assert sim.calculate("baseline_domestic_energy_vat", 2027)[0] == pytest.approx( + baseline + ) + # vat_change is a difference of float32 totals, good to about 1p. + assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-baseline, abs=0.01) def test_zero_rate_for_half_of_fy2027_is_day_weighted(): # A reform dated part-way through a model year (#2167, fixed by #2305) is # day-weighted on the blended electricity rate. Model year 2027 has 366 # days (6 April 2027 to 5 April 2028); a zero rate for the first 183 - # leaves half the year at 5%: 1,000 x 0.05 x 183 / 366 = £25. + # leaves half the year at 5%: 1,000 x 0.05 x 183 / 366 = £25. The + # baseline has 280 days at 5% (£38.25), so the change is £13.25. reform = Reform.from_dict( {GB_RATE: {"2027-04-06.2027-10-05": 0.0}}, country_id="uk" ) sim = Simulation(situation=household(year=2027), reform=reform) assert sim.calculate("domestic_energy_vat", 2027)[0] == pytest.approx(25) - assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-25) + assert sim.calculate("vat_change", 2027)[0] == pytest.approx( + 25 - 50 * FY2027_SHARE_AT_5_PERCENT, abs=0.01 + ) def test_reduced_rate_reform_reaches_gas_not_electricity(): @@ -230,18 +263,19 @@ def test_scalar_zero_gas_rate_leaves_electricity_vat_alone(): @pytest.mark.parametrize( "region, electricity_rate", [ - # GB, model year 2026: 183 of 365 days at 5%, so 0.05 x 183 / 365. + # GB, model year 2026: 178 of 365 days at 5%, so 0.05 x 178 / 365. ("LONDON", 0.05 * FY2026_SHARE_AT_5_PERCENT), ("SCOTLAND", 0.05 * FY2026_SHARE_AT_5_PERCENT), - # Northern Ireland is outside the zero rate: 5% all year. - ("NORTHERN_IRELAND", 0.05), + # Northern Ireland joins the zero rate on 1 April 2027: 360 of 365 + # days at 5%. + ("NORTHERN_IRELAND", 0.05 * NI_FY2026_SHARE_AT_5_PERCENT), ], ) def test_baseline_fy2026_window_by_nation_with_gas(region, electricity_rate): # £1,050 of electricity and £2,100 of gas, each VAT-exclusive at / 1.05: - # electricity: 1,000 x rate (GB 25.07, NI 50) + # electricity: 1,000 x rate (GB 24.38, NI 49.32) # gas: 2,000 x 5% = £100 in every nation - # so GB = 125.07 and NI = 150. + # so GB = 124.38 and NI = 149.32. vat = calc("domestic_energy_vat", region=region, electricity=1_050, gas=2_100) assert vat == pytest.approx(1_000 * electricity_rate + 100, abs=0.01) gas_only = calc("domestic_energy_vat", region=region, electricity=0, gas=2_100) diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index c29f5b3d24..cf77d0e8b6 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -353,10 +353,14 @@ def test_vat_is_never_negative_and_receipts_are_counted_once(households, year, f def electricity_rate(region, year): # SI 2026/987 zero-rates domestic electricity in Great Britain from - # 1 October 2026 to 31 March 2027: 183 of model year 2026's 365 days are - # at 5%. Northern Ireland and every other year stay at 5%. - if region != "NORTHERN_IRELAND" and year == 2026: - return 0.05 * 183 / 365 + # 1 October 2026, and Budget 2026 extends that to 1 July 2027 and to + # Northern Ireland from 1 April 2027. Model year 2026 has 178 of 365 days + # at 5% in Great Britain and 360 in Northern Ireland; model year 2027 has + # 280 of 366 in both. Every other year stays at 5%. + if year == 2026: + return 0.05 * (360 if region == "NORTHERN_IRELAND" else 178) / 365 + if year == 2027: + return 0.05 * 280 / 366 return 0.05 diff --git a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py index 8050829aba..9fd88bb3a1 100644 --- a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py +++ b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py @@ -31,7 +31,8 @@ class domestic_energy_vat(Variable): documentation = ( "VAT on the household's electricity and gas bills. Electricity is " "charged at its own rate, which is zero in Great Britain from 1 October " - "2026 to 31 March 2027; gas is charged at the reduced rate." + "2026 and in Northern Ireland from 1 April 2027, to 30 June 2027; gas " + "is charged at the reduced rate." ) entity = Household definition_period = YEAR From d4f803b03c6b56abfd66529950a02b10edbaaf76 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 11:17:50 -0400 Subject: [PATCH 05/29] Add the Household Energy Payment (Budget 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget 2026 (paragraph 6.24; section 4.3; Table 6.1 item 1) gives £100 to households in council tax bands A to D in England, delivered by local authorities from January 2027. The devolved governments get a Barnett share instead, which the model does not allocate. - Parameters gov.treasury.household_energy_payment.{amount, bands, countries}: £100 in model year 2026 only, bands A to D, England. - Variable household_energy_payment, counted in household_benefits, gov_spending and pre_budget_change_household_benefits as the Energy Bills Rebate is, with its own fiscal partition head and programme entry. - council_tax_band defaults to D, so 2026 England fixtures with no band now carry the £100; their expected totals are updated and say so. Co-Authored-By: Claude Opus 5.5 --- .../ab26-household-energy-payment.added.md | 1 + .../gov/treasury/cost-of-living-payments.md | 20 ++++ policyengine_uk/data/fiscal_partition.yaml | 10 +- .../household_energy_payment/amount.yaml | 23 ++++ .../household_energy_payment/bands.yaml | 15 +++ .../household_energy_payment/countries.yaml | 12 +++ .../household_energy_payment/index.yaml | 3 + policyengine_uk/programs.yaml | 13 +++ .../income/uc_statutory_pay.yaml | 7 +- .../pension_credit_war_pension_income.yaml | 7 +- .../gov/dwp/widowed_parents_allowance.yaml | 7 +- .../household_energy_payment.yaml | 102 ++++++++++++++++++ .../household/income/household_benefits.yaml | 15 +-- .../income/trading_loss_household_income.yaml | 3 +- .../pre_budget_change_household_benefits.py | 1 + policyengine_uk/variables/gov/gov_spending.py | 1 + .../household_energy_payment.py | 27 +++++ .../household/income/household_benefits.py | 1 + 18 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 changelog.d/ab26-household-energy-payment.added.md create mode 100644 policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml create mode 100644 policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml create mode 100644 policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml create mode 100644 policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml create mode 100644 policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py diff --git a/changelog.d/ab26-household-energy-payment.added.md b/changelog.d/ab26-household-energy-payment.added.md new file mode 100644 index 0000000000..29decaf5af --- /dev/null +++ b/changelog.d/ab26-household-energy-payment.added.md @@ -0,0 +1 @@ +Add the Household Energy Payment announced at Budget 2026: £100 in 2026-27 for households in council tax bands A to D in England, counted in household benefits and government spending. diff --git a/docs/book/programs/gov/treasury/cost-of-living-payments.md b/docs/book/programs/gov/treasury/cost-of-living-payments.md index 798c0fefe7..d2f99219f6 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -28,6 +28,26 @@ PolicyEngine models these via: - `council_tax_rebate/bands.yaml` — qualifying CT bands (A–D) - `council_tax_rebate/amount.yaml` — the £150 rebate amount +## Household Energy Payment (2026-27) + +Budget 2026 (paragraph 6.24) announced a one-off £100 Household Energy +Payment for households in council tax bands A to D in England, delivered +by local authorities from January 2027. PolicyEngine models it as +`household_energy_payment` (Household, YEAR), with parameters at +`parameters/gov/treasury/household_energy_payment/`: + +- `amount.yaml`: £100 in model year 2026 (6 April 2026 to 5 April 2027), + zero otherwise +- `bands.yaml`: qualifying bands (A to D) +- `countries.yaml`: qualifying countries (England) + +It counts in `household_benefits` and `gov_spending`, as the Energy Bills +Rebate does. The devolved governments receive a Barnett share instead, +which the model does not allocate, and the £100 million of further +local-authority funding for targeted support is not modelled. +`council_tax_band` defaults to band D, so a household with no band +recorded qualifies. + ## Cost-of-Living Payments (2022-23 and 2023-24) Two successive packages, both modelled through the variable diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index f242470b4a..92c5069938 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -1257,7 +1257,7 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative caseload: false - # Cost-of-living and energy support (none in force at current law) + # Cost-of-living and energy support - id: cost_of_living_support_payment label: Cost-of-living support payment variable: cost_of_living_support_payment @@ -1274,6 +1274,14 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative zero_at_baseline: expected + # Paid in 2026-27 only (Budget 2026), so not zero at current law. + - id: household_energy_payment + label: Household Energy Payment + variable: household_energy_payment + entity: household + report_group: cost_of_living_support + totals: [gov_spending, household_benefits] + sign: nonnegative - id: epg_subsidy label: Energy Price Guarantee subsidy variable: epg_subsidy diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml new file mode 100644 index 0000000000..5e6b9589fb --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml @@ -0,0 +1,23 @@ +description: One-off Household Energy Payment to each eligible household in the fiscal year. +values: + 0001-01-01: 0 + # Budget 2026 announces £100 for households in council tax bands A to D in + # England, delivered by local authorities from January 2027, within + # 2026-27. The model year runs from 6 April, so the payment is keyed to + # model year 2026 and ends with it. It is a spending decision, not + # legislation. + 2026-04-06: + value: 100 + metadata: + reference: + - title: Budget 2026, paragraph 6.24 (Household Energy Payment) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117 + - title: Budget 2026, section 4.3 (£100 for households in bands A-D in England from January 2027) + href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html + - title: Budget 2026, Table 6.1, item 1 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + 2027-04-06: 0 +metadata: + unit: currency-GBP + period: year + label: Household Energy Payment amount diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml new file mode 100644 index 0000000000..8c9f2df012 --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml @@ -0,0 +1,15 @@ +description: Council tax bands whose households receive the Household Energy Payment. +values: + 0001-01-01: + - A + - B + - C + - D +metadata: + economy: false + unit: list + period: year + label: Household Energy Payment council tax bands + reference: + - title: Budget 2026, paragraph 6.24 (households in bands A to D) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117 diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml new file mode 100644 index 0000000000..b381e4c14a --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml @@ -0,0 +1,12 @@ +description: Countries whose households receive the Household Energy Payment. The devolved governments receive Barnett funding instead, which the model does not allocate. +values: + 0001-01-01: + - ENGLAND +metadata: + economy: false + unit: list + period: year + label: Household Energy Payment countries + reference: + - title: Budget 2026, section 4.3 (bands A-D in England; devolved governments funded through the Barnett formula) + href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml new file mode 100644 index 0000000000..944ebdf2a6 --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml @@ -0,0 +1,3 @@ +description: The Household Energy Payment, a one-off £100 paid through the council tax system in 2026-27. +metadata: + label: Household Energy Payment diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 3803f6da39..825dced04e 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -590,6 +590,19 @@ programs: verified_end_year: 2023 notes: Temporary program, ended 2023 + - id: household_energy_payment + name: Household Energy Payment + full_name: Household Energy Payment (£100 council tax rebate) + category: Benefits + agency: Treasury + status: complete + coverage: England + variable: household_energy_payment + parameter_prefix: gov.treasury.household_energy_payment + verified_start_year: 2026 + verified_end_year: 2026 + notes: One-off £100 for households in council tax bands A to D in England, paid by local authorities from January 2027 (Budget 2026). The devolved governments' Barnett share and the £100 million local-authority discretionary fund are not modelled. + # --- Ofgem programs --- - id: energy_price_guarantee name: Energy Price Guarantee diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml index 0705307584..7da71d3a79 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml @@ -248,13 +248,14 @@ universal_credit: 4_878.80 # The award plus the pay. Without the pay the household has 5,098.80, so # 400 of pay raises its income by 180. - household_benefits: 5_278.80 - household_net_income: 5_278.80 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 5_378.80 + household_net_income: 5_378.80 # HBAI also counts the £150 Warm Home Discount the Universal Credit # award brings in England, as miscellaneous income. warm_home_discount: 150 hbai_household_net_income: 5_428.80 - gov_spending: 5_278.80 + gov_spending: 5_378.80 - name: Taxable statutory paternity pay reduces Universal Credit like wages # UC Regs 2013 reg. 55(4)(c) and (5): count SPP before deducting its tax. 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 c81788d84c..e295c5f111 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 @@ -323,10 +323,11 @@ 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 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 30 * 52 + 100 hbai_benefits: 30 * 52 - gov_spending: 30 * 52 - pre_budget_change_household_benefits: 30 * 52 + gov_spending: 30 * 52 + 100 + pre_budget_change_household_benefits: 30 * 52 + 100 # 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/policy/baseline/gov/dwp/widowed_parents_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml index 8b71b0b6af..fdf14391fe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml @@ -41,10 +41,11 @@ members: [person1] country: ENGLAND output: - household_benefits: 156.65 * 52 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 156.65 * 52 + 100 hbai_benefits: 156.65 * 52 - gov_spending: 156.65 * 52 - pre_budget_change_household_benefits: 156.65 * 52 + gov_spending: 156.65 * 52 + 100 + pre_budget_change_household_benefits: 156.65 * 52 + 100 # 8,145.80 is below the personal allowance. income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml new file mode 100644 index 0000000000..490b668f9f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml @@ -0,0 +1,102 @@ +# Household Energy Payment (Budget 2026, paragraph 6.24): £100 for +# households in council tax bands A to D in England, delivered from +# January 2027, so in model year 2026 (6 April 2026 to 5 April 2027). + +- name: Band A household in England gets £100 in 2026-27 + period: 2026 + absolute_error_margin: 0 + input: + region: NORTH_EAST + council_tax_band: A + output: + household_energy_payment: 100 + +- name: Band D household in England gets £100 in 2026-27 + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax_band: D + output: + household_energy_payment: 100 + +- name: Band E household in England gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: SOUTH_EAST + council_tax_band: E + output: + household_energy_payment: 0 + +- name: Band H household in England gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax_band: H + output: + household_energy_payment: 0 + +- name: Band B household in Wales gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + council_tax_band: B + output: + household_energy_payment: 0 + +- name: Band B household in Scotland gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + council_tax_band: B + output: + household_energy_payment: 0 + +- name: Household in Northern Ireland gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: NORTHERN_IRELAND + output: + household_energy_payment: 0 + +- name: No payment in 2025-26 + period: 2025 + absolute_error_margin: 0 + input: + region: NORTH_WEST + council_tax_band: A + output: + household_energy_payment: 0 + +- name: No payment in 2027-28 + period: 2027 + absolute_error_margin: 0 + input: + region: NORTH_WEST + council_tax_band: A + output: + household_energy_payment: 0 + +- name: The payment counts in household benefits and government spending + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + # Earnings high enough to rule out means-tested benefits. + employment_income: 100_000 + households: + household: + members: [adult] + region: EAST_MIDLANDS + council_tax_band: C + output: + household_energy_payment: 100 + household_benefits: 100 + gov_spending: 100 diff --git a/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml b/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml index 3bc7dfce22..afdfb33bd6 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml @@ -22,8 +22,9 @@ members: [jobseeker] output: jsa_contrib: 4_000 - household_benefits: 4_000 - household_gross_income: 4_000 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 4_100 + household_gross_income: 4_100 - name: Both partners' contributory JSA each count once period: 2026 @@ -45,8 +46,9 @@ members: [partner_1, partner_2] output: jsa_contrib: [2_000, 3_000] - household_benefits: 5_000 - household_gross_income: 5_000 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 5_100 + household_gross_income: 5_100 - name: The benefit uprating reform uprates contributory JSA once period: 2026 @@ -65,5 +67,6 @@ household: members: [jobseeker] output: - # 4,000 plus 10% of it. - household_benefits: 4_400 + # 4,000 plus the £100 Household Energy Payment (band D, the default, in + # England), plus 10% of both: the reform uprates every general benefit. + household_benefits: 4_510 diff --git a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml index 23595bcb87..e44490b022 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml @@ -39,4 +39,5 @@ income_tax: [3_486, 5_486] # -10,000 of loss + 2,000 of Income Tax saved. hbai_household_net_income: [24_320, 32_320] - household_net_income: [24_140, 32_140] + # Plus the £100 Household Energy Payment, which HBAI income leaves out. + household_net_income: [24_240, 32_240] diff --git a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py index a65a6bb943..7e3ec56e00 100644 --- a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py +++ b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py @@ -34,6 +34,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "carer_support_payment", "scottish_carer_supplement", "disability_basic_income", diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index 2ac8ed36ed..f8ff6bd218 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -36,6 +36,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "winter_fuel_allowance", "pawhp", "other_public_spending_budget_change", diff --git a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py new file mode 100644 index 0000000000..ab7012521e --- /dev/null +++ b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class household_energy_payment(Variable): + label = "Household Energy Payment" + documentation = ( + "One-off payment to households in the qualifying council tax bands and " + "countries, delivered by local authorities through the council tax " + "system. Budget 2026 sets £100 for bands A to D in England, from " + "January 2027." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + "https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117", + "https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html", + ] + + def formula(household, period, parameters): + p = parameters(period).gov.treasury.household_energy_payment + band = household("council_tax_band", period) + country = household("country", period) + in_band = np.isin(band.decode_to_str(), list(p.bands)) + in_country = np.isin(country.decode_to_str(), list(p.countries)) + return in_band * in_country * p.amount diff --git a/policyengine_uk/variables/household/income/household_benefits.py b/policyengine_uk/variables/household/income/household_benefits.py index 6c28430a2f..191050ba30 100644 --- a/policyengine_uk/variables/household/income/household_benefits.py +++ b/policyengine_uk/variables/household/income/household_benefits.py @@ -35,6 +35,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "winter_fuel_allowance", "pawhp", "tax_free_childcare", From d6b361121b8f59044bdfb6f493e040a7c994e749 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 12:13:28 -0400 Subject: [PATCH 06/29] Add the Pension Tax Allowance (Budget 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 6 April 2027 to 5 April 2030, people over State Pension age, or reaching it within the tax year, get a non-refundable Step 6 Income Tax reduction of 20% of the full new State Pension above the Personal Allowance, rounded up to the pound (£94 in 2027-28), tapered by £1 for every £3 of total income above the full new State Pension (Budget 2026 paragraph 6.182, policy costings p. 63, HM Treasury explainer). Each Step 6 reduction is capped at the tax the earlier ones leave (utils/income_tax_reductions.py); finance cost relief now uses the same helper. Universal Credit's deduction for tax on earnings reads income_tax_subtractions, so it sees the allowance. Also pins the Household Energy Payment outside HBAI income, as the 2022 council tax rebate is. Co-Authored-By: Claude Opus 5.5 --- .../ab26-pension-tax-allowance.added.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 1 + .../income_tax/income_tax_subtractions.yaml | 4 + .../pension_tax_allowance/in_effect.yaml | 16 ++ .../reliefs/pension_tax_allowance/index.yaml | 12 ++ .../reliefs/pension_tax_allowance/rate.yaml | 12 ++ .../state_pension_threshold.yaml | 18 ++ .../reliefs/pension_tax_allowance/taper.yaml | 10 + .../pension_tax_allowance.yaml | 197 ++++++++++++++++++ ...ousehold_energy_payment_income_measures.py | 50 +++++ .../test_pension_tax_allowance_properties.py | 167 +++++++++++++++ ..._pension_tax_allowance_universal_credit.py | 89 ++++++++ .../utils/income_tax_reductions.py | 16 ++ .../reliefs/pension_tax_allowance.py | 59 ++++++ .../reliefs/property_finance_cost_relief.py | 17 +- 15 files changed, 659 insertions(+), 10 deletions(-) create mode 100644 changelog.d/ab26-pension-tax-allowance.added.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml create mode 100644 policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml create mode 100644 policyengine_uk/tests/test_household_energy_payment_income_measures.py create mode 100644 policyengine_uk/tests/test_pension_tax_allowance_properties.py create mode 100644 policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py create mode 100644 policyengine_uk/utils/income_tax_reductions.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py diff --git a/changelog.d/ab26-pension-tax-allowance.added.md b/changelog.d/ab26-pension-tax-allowance.added.md new file mode 100644 index 0000000000..2663bdbd65 --- /dev/null +++ b/changelog.d/ab26-pension-tax-allowance.added.md @@ -0,0 +1 @@ +Add the Pension Tax Allowance announced at Budget 2026: from 6 April 2027 to 5 April 2030, people over State Pension age (or reaching it within the tax year) get a non-refundable Income Tax reduction of 20% of the full new State Pension above the Personal Allowance, rounded up to the pound (£94 in 2027-28), tapered by £1 for every £3 of total income above the full new State Pension. Universal Credit's deduction for tax on earnings sees it. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 92c5069938..457b73954c 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -720,6 +720,7 @@ exclusions: - {variable: married_couples_allowance_deduction, kind: component, head: income_tax, note: "Inside capped_mcad."} - {variable: other_tax_credits, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: property_finance_cost_relief, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: pension_tax_allowance, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: basic_rate_earned_income_tax, kind: assessment, head: income_tax, note: "Earned income tax by band; earned_income_tax does not read it."} - {variable: higher_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} - {variable: add_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml index e6bc103f75..95498b4373 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml @@ -7,8 +7,12 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 - title: Income Tax Act 2007 s. 26(1)(a) href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 + - title: Budget 2026, paragraph 6.182 (Pension Tax Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 values: 0000-01-01: - capped_mcad - other_tax_credits - property_finance_cost_relief + # Budget 2026, 2027-28 to 2029-30; zero in other years. + - pension_tax_allowance diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml new file mode 100644 index 0000000000..6e70aa1f4e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml @@ -0,0 +1,16 @@ +description: Whether the Pension Tax Allowance applies in the tax year. +values: + 0001-01-01: false + 2027-04-06: true + # "The Pension Tax Allowance will apply from April 2027 for tax years + # 2027-28, 2028-29 and 2029-30." + 2030-04-06: false +metadata: + unit: bool + period: year + label: Pension Tax Allowance in effect + reference: + - title: HM Treasury, Pension Tax Allowance explainer, FAQ (tax years 2027-28 to 2029-30) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026, Table 6.1, item 52 (from 6 April 2027 until 5 April 2030) + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml new file mode 100644 index 0000000000..ae02ce6b65 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml @@ -0,0 +1,12 @@ +description: The Pension Tax Allowance, a non-refundable Income Tax reduction for people at or reaching State Pension age whose total income is up to, or a little above, the full new State Pension. Budget 2026 policy for 2027-28 to 2029-30, not yet law. +metadata: + label: Pension Tax Allowance + reference: + - title: Budget 2026, paragraph 6.182 (Income Tax, Pension Tax Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 + - title: Budget 2026 policy costings, page 63 + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=63 + - title: HM Treasury, Pension Tax Allowance explainer (28 October 2026) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026, Table 6.1, item 52 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml new file mode 100644 index 0000000000..9536980d30 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml @@ -0,0 +1,12 @@ +description: Rate applied to the full new State Pension above the Personal Allowance to give the full Pension Tax Allowance. It is the UK basic rate for every taxpayer, Scottish taxpayers included. +values: + 2027-04-06: 0.2 +metadata: + unit: /1 + period: year + label: Pension Tax Allowance rate + reference: + - title: HM Treasury, Pension Tax Allowance explainer, FAQ (Scottish taxpayers use the 20% basic rate) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026 policy costings, page 63 (the tax otherwise due on the new State Pension above the Personal Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=63 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml new file mode 100644 index 0000000000..6bdb91c45c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml @@ -0,0 +1,18 @@ +description: Total income up to which the full Pension Tax Allowance applies, the full annual value of the new State Pension for the tax year. The allowance tapers away above it. +values: + # 52 weeks at £250.70. The explainer: "From the tax year beginning + # 6 April 2027, the full annual value of the new State Pension will + # increase to £13,036.40", worked out as the full new State Pension + # receivable from 6 April 2027 to 5 April 2028. + 2027-04-06: 13_036.40 +metadata: + unit: currency-GBP + period: year + label: Pension Tax Allowance State Pension threshold + # "For tax years 2028-29 and 2029-30, the value of the Pension Tax + # Allowance will be uprated in line with the full annual value of the new + # State Pension." Later years follow the weekly new State Pension rate. + uprating: gov.dwp.state_pension.new_state_pension.amount + reference: + - title: HM Treasury, Pension Tax Allowance explainer, section 1 and FAQ + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml new file mode 100644 index 0000000000..1e55a615dd --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml @@ -0,0 +1,10 @@ +description: Pension Tax Allowance withdrawn for each pound of total income above the State Pension threshold. It falls by £1 for every £3. +values: + 2027-04-06: 0.333333333333 +metadata: + unit: /1 + period: year + label: Pension Tax Allowance taper rate + reference: + - title: HM Treasury, Pension Tax Allowance explainer, section 1 ("For every £3 of income above the full level of the new State Pension, your Pension Tax Allowance will reduce by £1") + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml new file mode 100644 index 0000000000..2d6f5f4816 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml @@ -0,0 +1,197 @@ +# Budget 2026 Pension Tax Allowance (Table 6.1, item 52; paragraph 6.182). +# +# A non-refundable Income Tax reduction for people over State Pension age, or +# reaching it within the tax year, from 6 April 2027 to 5 April 2030. The full +# allowance is 20% of the full new State Pension above the Personal Allowance, +# rounded up to the pound: 20% x (13,036.40 - 12,570) = 93.28, so 94 in +# 2027-28. It tapers by 1 for every 3 of total income above 13,036.40, so it +# is gone from 13,318.40 (the explainer's "13,319 or more"). +# +# The first five cases are the worked examples in HM Treasury's Pension Tax +# Allowance explainer. + +- name: Tom, below the Personal Allowance, has no tax to remove + period: 2027 + absolute_error_margin: 0.01 + input: + age: 75 + state_pension: 10_300 + output: + pension_tax_allowance: 0 + income_tax: 0 + +- name: Steph, on the full new State Pension, has her 93.28 of tax removed + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + output: + # (13,036.40 - 12,570) x 20% + income_tax_pre_charges: 93.28 + pension_tax_allowance: 93.28 + income_tax: 0 + +- name: Anne, on a partial State Pension and a private pension, has her 80 removed + period: 2027 + absolute_error_margin: 0.01 + input: + age: 72 + state_pension: 9_000 + private_pension_income: 3_970 + output: + # (12,970 - 12,570) x 20%; the 94 allowance is capped at the tax due + income_tax_pre_charges: 80 + pension_tax_allowance: 80 + income_tax: 0 + +- name: Jonathan, deferring his pension with income in the taper, gets 44 + period: 2027 + absolute_error_margin: 0.01 + input: + age: 68 + employment_income: 12_000 + property_income: 1_186.40 + output: + total_income: 13_186.40 + # 94 - (13,186.40 - 13,036.40) / 3 + pension_tax_allowance: 44 + # The Personal Allowance covers the salary and 570 of the property + # profit; 616.40 x 24% (the Budget 2026 property basic rate) = 147.94 + income_tax_pre_charges: 147.94 + income_tax: 103.94 + +- name: Nadia, above 13,319, gets no allowance + period: 2027 + absolute_error_margin: 0.01 + input: + age: 71 + # The explainer's Nadia also has some savings income. The 166 it quotes + # is 20% of everything above the Personal Allowance, so this case puts + # all 13,400 in her private pension: savings inside the starting rate for + # savings would bring her tax below 166. + private_pension_income: 13_400 + output: + pension_tax_allowance: 0 + # (13,400 - 12,570) x 20% + income_tax: 166 + +- name: The allowance runs out at three times the full allowance above the threshold + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + # 13,036.40 + 3 x 94 + state_pension: 13_318.40 + output: + pension_tax_allowance: 0 + income_tax: 149.68 + +- name: A pound before the end of the taper leaves a third of a pound + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_317.40 + output: + pension_tax_allowance: 0.33 + # 747.40 x 20% = 149.48, less the allowance + income_tax: 149.15 + +- name: Scottish taxpayers get the allowance at 20%, capped at their starter-rate tax + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + region: SCOTLAND + output: + pays_scottish_income_tax: true + # 466.40 x 19% starter rate + income_tax_pre_charges: 88.62 + pension_tax_allowance: 88.62 + income_tax: 0 + +- name: In the taper, a Scottish taxpayer's allowance is still worked out at 20% + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + private_pension_income: 13_186.40 + region: SCOTLAND + output: + # 94 - 150 / 3; at the 19% starter rate the full allowance would have + # been 89, leaving 39 + pension_tax_allowance: 44 + # 616.40 x 19% = 117.12 + income_tax: 73.12 + +- name: Someone under State Pension age with the same income gets nothing + period: 2027 + absolute_error_margin: 0.01 + input: + age: 60 + employment_income: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax_pre_charges: 93.28 + +- name: Reaching State Pension age on the last day of the tax year qualifies + period: 2027 + absolute_error_margin: 0.01 + input: + # State Pension age is 67 for people born after 5 March 1961, so this + # person reaches it on 5 April 2028 + age: 66 + date_of_birth: 19610405 + state_pension: 13_036.40 + output: + is_SP_age: false + pension_tax_allowance: 93.28 + income_tax: 0 + +- name: Reaching State Pension age on the first day of the next tax year does not + period: 2027 + absolute_error_margin: 0.01 + input: + # Reaches 67 on 6 April 2028, in 2028-29 + age: 66 + date_of_birth: 19610406 + state_pension: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax: 93.28 + +- name: There is no allowance in 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax: 93.28 + +- name: There is no allowance in 2030-31, after it ends on 5 April 2030 + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + private_pension_income: 13_000 + output: + pension_tax_allowance: 0 + income_tax: 86 + +- name: In 2028-29 the threshold follows the new State Pension and the allowance is recomputed + period: 2028 + absolute_error_margin: 0.01 + input: + age: 70 + # The threshold is 13,036.40 uprated with the new State Pension: 13,362.33 + # on the current forecast, so 20% x (13,362.33 - 12,570) = 158.47 and the + # full allowance is 159. 300 above the threshold takes 100 off it. + private_pension_income: 13_662.33 + output: + pension_tax_allowance: 59 + # 1,092.33 x 20% = 218.47, less the allowance + income_tax: 159.47 diff --git a/policyengine_uk/tests/test_household_energy_payment_income_measures.py b/policyengine_uk/tests/test_household_energy_payment_income_measures.py new file mode 100644 index 0000000000..2a03523657 --- /dev/null +++ b/policyengine_uk/tests/test_household_energy_payment_income_measures.py @@ -0,0 +1,50 @@ +"""The Household Energy Payment in the income measures. + +The £100 payment (Budget 2026, paragraph 6.24) raises household net income +but, like the 2022 council tax rebate (energy_bills_rebate), is not in +hbai_household_net_income, so it moves no poverty or inequality figure. +""" + +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +WITHOUT_PAYMENT = {"gov.treasury.household_energy_payment.amount": {str(YEAR): 0}} +SITUATION = { + "people": {"adult": {"age": {YEAR: 40}, "employment_income": {YEAR: 30_000}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "region": {YEAR: "NORTH_WEST"}, + "council_tax_band": {YEAR: "B"}, + } + }, +} + + +def measures(reform=None): + sim = Simulation(situation=SITUATION, reform=reform) + return { + v: float(sim.calculate(v, YEAR)[0]) + for v in [ + "household_energy_payment", + "household_net_income", + "hbai_household_net_income", + ] + } + + +def test_payment_raises_net_income_but_not_hbai_income(): + law, without = measures(), measures(WITHOUT_PAYMENT) + assert law["household_energy_payment"] == 100 + assert without["household_energy_payment"] == 0 + assert law["household_net_income"] - without["household_net_income"] == ( + pytest.approx(100, abs=0.01) + ) + assert law["hbai_household_net_income"] == pytest.approx( + without["hbai_household_net_income"], abs=0.01 + ) diff --git a/policyengine_uk/tests/test_pension_tax_allowance_properties.py b/policyengine_uk/tests/test_pension_tax_allowance_properties.py new file mode 100644 index 0000000000..2d3b7aa72a --- /dev/null +++ b/policyengine_uk/tests/test_pension_tax_allowance_properties.py @@ -0,0 +1,167 @@ +"""Property tests for the Budget 2026 Pension Tax Allowance. + +The allowance (paragraph 6.182; Table 6.1, item 52) is 20% of the full new +State Pension above the Personal Allowance, rounded up to the pound, tapered +by 1 for every 3 of total income above the full new State Pension, for people +over State Pension age or reaching it within the tax year, from 6 April 2027 +to 5 April 2030. It is a reduction at Step 6 of the Income Tax calculation, so +it cannot take the tax below zero. Each test draws people and checks +identities that hold for every input: + +- the allowance lies between zero and the tapered full allowance, so it is + zero from three full allowances above the threshold; +- people who reach State Pension age after the tax year get nothing; +- Income Tax falls by exactly the allowance relative to the same law without + it, and never goes below zero; +- the taper has no cliff edge: each extra pound of pension income takes at + most a third of a pound off the allowance. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = [2027, 2028, 2029] +CASES = 24 +STEP = 25.0 +PTA = "gov.hmrc.income_tax.reliefs.pension_tax_allowance" +WITHOUT_PTA = {f"{PTA}.in_effect": {str(year): False for year in YEARS}} +REGIONS = ["LONDON", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] + +amount = st.one_of( + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=12_000, max_value=14_500), + st.sampled_from([0.0, 12_570.0, 13_036.40, 13_318.40]), +) +case = st.tuples( + st.sampled_from([60, 65, 66, 67, 70, 85]), # age + amount, # State Pension + amount, # private pension + st.one_of(st.just(0.0), amount), # employment income + st.one_of(st.just(0.0), st.floats(min_value=0, max_value=3_000)), # savings +) +cases = st.lists(case, min_size=CASES, max_size=CASES) +years = st.sampled_from(YEARS) + + +def situation(): + # The second half repeats the first with STEP more private pension. + n = 2 * CASES + return { + "people": {f"person_{i}": {} for i in range(n)}, + "benunits": {f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(n)}, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {year: REGIONS[i % len(REGIONS)] for year in YEARS}, + } + for i in range(n) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=WITHOUT_PTA + ) + + +VARIABLES = [ + "months_since_state_pension_age", + "personal_allowance", + "total_income", + "income_tax_pre_charges", + "pension_tax_allowance", + "income_tax", +] + + +def run(sims, drawn, year): + age, state_pension, private_pension, employment, savings = ( + np.tile(np.array(column), 2) for column in zip(*drawn) + ) + private_pension[CASES:] += STEP + results = [] + for sim in sims: + sim.set_input("age", year, age) + sim.set_input("state_pension", year, state_pension) + sim.set_input("private_pension_income", year, private_pension) + sim.set_input("employment_income", year, employment) + sim.set_input("savings_interest_income", year, savings) + sim.reset_calculations() + results.append({v: sim.calculate(v, year).astype(float) for v in VARIABLES}) + return results + + +def full_allowance(sim, year): + p = sim.tax_benefit_system.parameters.gov.hmrc.income_tax + threshold = p.reliefs.pension_tax_allowance.state_pension_threshold(f"{year}-04-30") + allowance = p.allowances.personal_allowance.amount(f"{year}-04-30") + return threshold, np.ceil(round(0.2 * (threshold - allowance), 6)) + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases, years) +def test_allowance_lies_between_zero_and_the_tapered_full_allowance(sims, drawn, year): + law, _ = run(sims, drawn, year) + threshold, full = full_allowance(sims[0], year) + pta = law["pension_tax_allowance"] + excess = np.maximum(0, law["total_income"] - threshold) + tapered = np.maximum(0, full - excess / 3) + tol = tolerance(law["total_income"]) + assert full > 0 + assert (pta >= 0).all() + assert (pta <= tapered + tol).all() + assert (pta[excess >= 3 * full] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_people_reaching_state_pension_age_after_the_year_get_nothing( + sims, drawn, year +): + law, _ = run(sims, drawn, year) + later = law["months_since_state_pension_age"] <= -6 + assert (law["pension_tax_allowance"][later] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_income_tax_falls_by_exactly_the_allowance(sims, drawn, year): + law, without = run(sims, drawn, year) + pta = law["pension_tax_allowance"] + fall = without["income_tax"] - law["income_tax"] + tol = tolerance(without["income_tax"]) + assert (law["income_tax"] >= 0).all() + assert (np.abs(fall - pta) <= tol).all() + assert (without["pension_tax_allowance"] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_the_allowance_falls_by_at_most_a_third_of_extra_income(sims, drawn, year): + law, _ = run(sims, drawn, year) + pta = law["pension_tax_allowance"] + base, more = pta[:CASES], pta[CASES:] + # Capped at the tax due, which rises with income, the allowance can fall + # no faster than the 1-in-3 taper: no cliff edge at the threshold or at + # the point where the cap stops binding. + assert (base - more <= STEP / 3 + 0.01).all() diff --git a/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py b/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py new file mode 100644 index 0000000000..76bd357299 --- /dev/null +++ b/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py @@ -0,0 +1,89 @@ +"""The Pension Tax Allowance in Universal Credit. + +Universal Credit deducts the income tax paid on earnings (UC Regulations 2013 +reg. 55(5)(b) and 57(2)), which it works out from the rates less the Step 6 +reductions in gov.hmrc.income_tax.income_tax_subtractions. The allowance is +on that list, so for a mixed-age couple whose older partner works, it raises +earned income and UC takes back the taper share of it. UC counts pension +income before tax (reg. 66), so there the allowance leaves UC unchanged. + +The working case is Pavel Makarchuk's on uk-autumn-budget-dashboard-2026 #290: +an unmarried couple aged 70 and 60 renting in the North West in 2027-28, the +older partner earning £13,036.40 with the State Pension deferred. +""" + +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +WITHOUT_PTA = { + "gov.hmrc.income_tax.reliefs.pension_tax_allowance.in_effect": {str(YEAR): False} +} + + +def couple(older_income): + return { + "people": { + "older": { + "age": {YEAR: 70}, + **{k: {YEAR: v} for k, v in older_income.items()}, + }, + "younger": {"age": {YEAR: 60}}, + }, + "benunits": { + "benunit": { + "members": ["older", "younger"], + # Unmarried, so no Marriage Allowance transfer + "is_married": {YEAR: False}, + } + }, + "households": { + "household": { + "members": ["older", "younger"], + "region": {YEAR: "NORTH_WEST"}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: 7_200}, + } + }, + } + + +def results(situation, reform=None): + sim = Simulation(situation=situation, reform=reform) + return { + "pension_tax_allowance": float(sim.calculate("pension_tax_allowance", YEAR)[0]), + "universal_credit": float(sim.calculate("universal_credit", YEAR)[0]), + "household_net_income": float(sim.calculate("household_net_income", YEAR)[0]), + "taper": sim.tax_benefit_system.parameters.gov.dwp.universal_credit.means_test.reduction_rate( + f"{YEAR}-04-30" + ), + } + + +def test_uc_takes_back_the_taper_share_of_the_allowance_on_earnings(): + situation = couple({"employment_income": 13_036.40}) + law, without = results(situation), results(situation, WITHOUT_PTA) + assert law["pension_tax_allowance"] == pytest.approx(93.28, abs=0.01) + assert without["universal_credit"] > 0 + uc_change = law["universal_credit"] - without["universal_credit"] + # 55% x 93.28 = 51.30, leaving the couple 41.97 better off + assert uc_change == pytest.approx(-law["taper"] * 93.28, abs=0.01) + assert uc_change == pytest.approx(-51.30, abs=0.01) + assert law["household_net_income"] - without["household_net_income"] == ( + pytest.approx(41.97, abs=0.01) + ) + + +def test_uc_ignores_the_allowance_on_pension_income(): + situation = couple({"private_pension_income": 13_036.40}) + law, without = results(situation), results(situation, WITHOUT_PTA) + assert law["pension_tax_allowance"] == pytest.approx(93.28, abs=0.01) + assert law["universal_credit"] == pytest.approx( + without["universal_credit"], abs=0.01 + ) + assert law["household_net_income"] - without["household_net_income"] == ( + pytest.approx(93.28, abs=0.01) + ) diff --git a/policyengine_uk/utils/income_tax_reductions.py b/policyengine_uk/utils/income_tax_reductions.py new file mode 100644 index 0000000000..d20fb4f4ce --- /dev/null +++ b/policyengine_uk/utils/income_tax_reductions.py @@ -0,0 +1,16 @@ +"""Step 6 of the Income Tax calculation (ITA 2007 s. 23): tax reductions.""" + + +def earlier_tax_reductions(income_tax, name: str) -> list: + """The tax reductions listed before ``name`` in income_tax_subtractions. + + ITA 2007 s. 29(2)-(3) allows each reduction only so far as there is Step 5 + tax left after the reductions already deducted. The order does not change + their total, so the model deducts them in list order: each capped reduction + takes the tax the ones before it leave. A reduction not in the list (a + reform has removed it) comes after every listed one. + """ + subtractions = list(income_tax.income_tax_subtractions) + if name in subtractions: + return subtractions[: subtractions.index(name)] + return subtractions diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py new file mode 100644 index 0000000000..6eb04b4942 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import MONTHS_FROM_TAX_YEAR_START_TO_MID_YEAR +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions + + +class pension_tax_allowance(Variable): + value_type = float + entity = Person + label = "Pension Tax Allowance" + documentation = ( + "Income Tax reduction for people over State Pension age or reaching it " + "in the tax year, from 2027-28 to 2029-30. The full allowance is 20% of " + "the full new State Pension above the Personal Allowance, rounded up to " + "the pound (£94 in 2027-28). It falls by £1 for every £3 of total " + "income above the full new State Pension. It is non-refundable: it is " + "deducted at Step 6 after the reductions listed before it in " + "income_tax_subtractions, only so far as there is Step 5 tax left, " + "and it does not change the Personal Allowance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Budget 2026, paragraph 6.182", + href="https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135", + ), + dict( + title="HM Treasury, Pension Tax Allowance explainer", + href="https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer", + ), + dict( + title="Income Tax Act 2007, s. 23 (Step 6) and s. 29(2)-(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + p = income_tax.reliefs.pension_tax_allowance + threshold = p.state_pension_threshold + personal_allowance = income_tax.allowances.personal_allowance.amount + # The explainer rounds 20% of £466.40 (£93.28) up to £94, and tapers + # from £94: Jonathan, £150 above the threshold, gets £94 - £50 = £44. + # Rounding to a millionth first keeps a whole-pound value whole. + full = np.ceil(np.round(p.rate * max_(0, threshold - personal_allowance), 6)) + excess = max_(0, person("total_income", period) - threshold) + allowance = max_(0, full - p.taper * excess) + # Over State Pension age or reaching it within the tax year: attaining + # it before 6 April of the next year, six months after 6 October. + eligible = ( + person("months_since_state_pension_age", period) + > -MONTHS_FROM_TAX_YEAR_START_TO_MID_YEAR + ) + earlier = earlier_tax_reductions(income_tax, "pension_tax_allowance") + tax_left = max_( + 0, + person("income_tax_pre_charges", period) - add(person, period, earlier), + ) + return p.in_effect * eligible * min_(allowance, tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index b23956b772..e87c8cf614 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions class property_finance_cost_relief(Variable): @@ -11,8 +12,9 @@ class property_finance_cost_relief(Variable): "rate before 2027-28) times the finance costs relieved this year. " "The rate is the same for Scottish taxpayers, whose property income " "is taxed at Scottish rates. It is deducted at Step 6 of the income " - "tax calculation after the person's other tax reductions, and only " - "so far as there is tax left to deduct it from." + "tax calculation after the reductions listed before it in " + "income_tax_subtractions, and only so far as there is tax left to " + "deduct it from." ) definition_period = YEAR unit = GBP @@ -41,17 +43,12 @@ def formula(person, period, parameters): "property_finance_costs_relieved", period ) # The order of Step 6 reductions does not change their total, so the - # person's other reductions come first; this one is limited to the + # reductions listed before this one come first; it is limited to the # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the # High Income Child Benefit Charge, are not reduced. - other_reductions = [ - variable - for variable in income_tax.income_tax_subtractions - if variable != "property_finance_cost_relief" - ] + earlier = earlier_tax_reductions(income_tax, "property_finance_cost_relief") tax_left = max_( 0, - person("income_tax_pre_charges", period) - - add(person, period, other_reductions), + person("income_tax_pre_charges", period) - add(person, period, earlier), ) return min_(reduction, tax_left) From b5d2d77ff3ccd2ee1f83f17f69455009a8ddf8f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 12:13:28 -0400 Subject: [PATCH 07/29] Taper Carer's Allowance above the earnings limit (Budget 2026) Budget 2026 (paragraph 6.13, costings p. 16) replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a 100% taper on the earnings above it, in England and Wales, from a date within 2030-31. Until DWP confirms the date the taper starts on 6 October 2030; the day-weighted share gives 2030-31 182 of its 365 days. The weekly excess over the limit is a variable of its own, which the gainful-employment test now reads. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 1 + .../earnings_taper/applies.yaml | 14 ++ .../earnings_taper/countries.yaml | 12 ++ .../earnings_taper/index.yaml | 10 + .../carers_allowance/earnings_taper/rate.yaml | 10 + .../test_carers_allowance_earnings_taper.py | 187 ++++++++++++++++++ .../dwp/carers_allowance_pre_couple_limit.py | 14 +- ...s_allowance_weekly_earnings_above_limit.py | 37 ++++ ...gainfully_employed_for_carers_allowance.py | 7 +- 9 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 changelog.d/ab26-carers-allowance-taper.changed.md create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml create mode 100644 policyengine_uk/tests/test_carers_allowance_earnings_taper.py create mode 100644 policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md new file mode 100644 index 0000000000..78041238c2 --- /dev/null +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -0,0 +1 @@ +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml new file mode 100644 index 0000000000..06cafededb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml @@ -0,0 +1,14 @@ +description: Share of the fiscal year in which Carer's Allowance tapers on earnings above the limit instead of stopping in full. +values: + # The taper "will begin within the 2030-31 financial year, with a precise + # start date confirmed in due course" (policy costings, page 16). Until the + # date is confirmed, the model starts it on 6 October 2030, the middle of + # 2030-31. fiscal_year_blend day-weights the year, so 2030-31 carries 182 + # of its 365 days. Change the date here when DWP confirms it. + 0001-01-01: 0 + 2030-10-06: 1 +metadata: + unit: /1 + period: year + fiscal_year_blend: true + label: Carer's Allowance earnings taper share of year diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml new file mode 100644 index 0000000000..8dbb706c6d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml @@ -0,0 +1,12 @@ +description: Countries in which Carer's Allowance tapers on earnings above the limit. Scotland pays Carer Support Payment instead, and Northern Ireland keeps the withdrawal in full. +values: + 0001-01-01: + - ENGLAND + - WALES +metadata: + unit: list + period: year + label: Carer's Allowance earnings taper countries + reference: + - title: Budget 2026, paragraph 6.13 (the taper will "apply in England and Wales") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml new file mode 100644 index 0000000000..1859a223d7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml @@ -0,0 +1,10 @@ +description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. +metadata: + label: Carer's Allowance earnings taper + reference: + - title: Budget 2026, paragraph 6.13 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 + - title: Budget 2026 policy costings, Carer's Allowance taper (page 16) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=16 + - title: Budget 2026, Table 6.1, item 7 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml new file mode 100644 index 0000000000..36cd2be504 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml @@ -0,0 +1,10 @@ +description: Carer's Allowance withdrawn for each pound of weekly earnings above the earnings limit, where the earnings taper applies. +values: + 0001-01-01: 1 +metadata: + unit: /1 + period: year + label: Carer's Allowance earnings taper rate + reference: + - title: Budget 2026, paragraph 6.13 (a 100% taper on earnings in excess of the limit) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py new file mode 100644 index 0000000000..02582db692 --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -0,0 +1,187 @@ +"""The Budget 2026 Carer's Allowance earnings taper. + +Budget 2026 (paragraph 6.13; costings page 16; Table 6.1, item 7) replaces +the withdrawal of Carer's Allowance in full above the weekly earnings limit +with a 100% taper on the earnings above it, in England and Wales, from a date +within 2030-31 (6 October 2030 until DWP confirms it). The tests read the +limit and the weekly rate from the parameter tree, so they hold whatever the +limit's projection (gov.dwp.carers_allowance.earnings_limit). Examples first, +then identities that hold for every input: + +- in England and Wales from 2031-32 the weekly allowance is the rate less the + earnings above the limit, floored at zero; +- the taper never pays less than the cliff edge it replaces; +- weekly earnings plus the allowance never fall as earnings rise: no cliff; +- Scotland (Carer Support Payment) and Northern Ireland are unchanged. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +TAPER = "gov.dwp.carers_allowance.earnings_taper" +FULL_YEAR = 2031 +START_YEAR = 2030 +# 6 October 2030 to 5 April 2031 is 182 of 2030-31's 365 days. +START_YEAR_SHARE = 182 / 365 +WITHOUT_TAPER = {f"{TAPER}.applies": {str(year): 0 for year in (START_YEAR, FULL_YEAR)}} +REGIONS = ["NORTH_WEST", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +TAPER_REGIONS = ["NORTH_WEST", "WALES"] + + +def carer(weekly_earnings, year, region): + return { + "people": { + "carer": { + "age": {year: 45}, + "care_hours": {year: 40}, + "carers_allowance_earnings": {year: 52 * weekly_earnings}, + } + }, + "benunits": {"benunit": {"members": ["carer"]}}, + "households": {"household": {"members": ["carer"], "region": {year: region}}}, + } + + +def weekly_allowance(weekly_earnings, year, region="NORTH_WEST", reform=None): + sim = Simulation(situation=carer(weekly_earnings, year, region), reform=reform) + return float(sim.calculate("carers_allowance_pre_couple_limit", year)[0]) / 52 + + +def ca_parameters(year): + sim = Simulation(situation=carer(0, year, "NORTH_WEST")) + ca = sim.tax_benefit_system.parameters.gov.dwp.carers_allowance + return ca.earnings_limit(f"{year}-04-30"), ca.rate(f"{year}-04-30") + + +def test_at_the_limit_the_full_rate_is_paid(): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit, FULL_YEAR) == pytest.approx(rate, abs=0.01) + + +@pytest.mark.parametrize("region", TAPER_REGIONS) +def test_ten_pounds_over_the_limit_takes_ten_pounds_off(region): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit + 10, FULL_YEAR, region) == pytest.approx( + rate - 10, abs=0.01 + ) + + +def test_earnings_a_full_rate_over_the_limit_leave_nothing(): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(np.ceil(limit + rate) + 1, FULL_YEAR) == 0 + + +@pytest.mark.parametrize("region", ["NORTHERN_IRELAND", "SCOTLAND"]) +def test_no_taper_outside_england_and_wales(region): + limit, _ = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit + 10, FULL_YEAR, region) == 0 + + +def test_no_taper_before_2030_31(): + limit, _ = ca_parameters(2029) + assert weekly_allowance(limit + 10, 2029) == 0 + + +def test_2030_31_carries_the_taper_for_the_weeks_from_6_october(): + limit, rate = ca_parameters(START_YEAR) + assert weekly_allowance(limit + 10, START_YEAR) == pytest.approx( + START_YEAR_SHARE * (rate - 10), abs=0.01 + ) + + +def test_below_the_limit_2030_31_pays_the_full_rate(): + limit, rate = ca_parameters(START_YEAR) + assert weekly_allowance(limit - 1, START_YEAR) == pytest.approx(rate, abs=0.01) + + +CASES = 24 +STEP = 1.0 +weekly = st.one_of( + st.integers(min_value=0, max_value=400).map(float), + st.floats(min_value=150, max_value=360), +) +cases = st.lists(weekly, min_size=CASES, max_size=CASES) + + +def situation(): + # The second half repeats the first with STEP more weekly earnings. + n = 2 * CASES + return { + "people": { + f"person_{i}": {"age": {FULL_YEAR: 45}, "care_hours": {FULL_YEAR: 40}} + for i in range(n) + }, + "benunits": {f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(n)}, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {FULL_YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(n) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=WITHOUT_TAPER + ) + + +def run(sims, drawn): + earnings = np.tile(np.array(drawn), 2) + earnings[CASES:] += STEP + results = [] + for sim in sims: + sim.set_input("carers_allowance_earnings", FULL_YEAR, 52 * earnings) + sim.reset_calculations() + results.append( + sim.calculate("carers_allowance_pre_couple_limit", FULL_YEAR).astype(float) + / 52 + ) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(2 * CASES)]) + return earnings, regions, results[0], results[1] + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_the_allowance_is_the_rate_less_earnings_above_the_limit(sims, drawn): + earnings, regions, law, _ = run(sims, drawn) + limit, rate = ca_parameters(FULL_YEAR) + expected = np.maximum(0, rate - np.maximum(0, earnings - limit)) + tapers = np.isin(regions, TAPER_REGIONS) + # A penny for the disregarded fraction of a penny of weekly earnings. + assert (np.abs(law - expected)[tapers] <= 0.011).all() + + +@SETTINGS +@given(cases) +def test_the_taper_never_pays_less_than_the_cliff_edge(sims, drawn): + _, regions, law, cliff = run(sims, drawn) + assert (law >= cliff - 1e-4).all() + unchanged = ~np.isin(regions, TAPER_REGIONS) + assert (np.abs(law - cliff)[unchanged] <= 1e-4).all() + + +@SETTINGS +@given(cases) +def test_earnings_plus_the_allowance_never_fall_as_earnings_rise(sims, drawn): + earnings, regions, law, _ = run(sims, drawn) + total = earnings + law + tapers = np.isin(regions, TAPER_REGIONS)[:CASES] + rise = total[CASES:] - total[:CASES] + assert (rise[tapers] >= -0.011).all() diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py index d2fb955e66..97a9bd67d5 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py @@ -32,11 +32,21 @@ def formula(person, period, parameters): gainfully_employed = person( "is_gainfully_employed_for_carers_allowance", period ) + # Budget 2026 (para 6.13) replaces that cliff edge in England and + # Wales with a taper: the weekly allowance less taper.rate for each + # pound of weekly earnings above the limit. taper.applies is the + # share of the year in which it does, so a year it starts in part + # pays the tapered amount for that share of its weeks. + taper = ca.earnings_taper + country = person.household("country", period).decode_to_str() + tapers = np.isin(country, list(taper.countries)) * taper.applies + excess = person("carers_allowance_weekly_earnings_above_limit", period) + tapered = max_(0, ca.rate - taper.rate * excess) + weekly = where(gainfully_employed, tapers * tapered, ca.rate) eligible = ( ~(in_scotland & csp_replaces_ca) & (meets_work_condition | receives_ca) - & ~gainfully_employed & would_claim & meets_age_condition ) - return eligible * ca.rate * WEEKS_IN_YEAR + return eligible * weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py new file mode 100644 index 0000000000..f4717dc09c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class carers_allowance_weekly_earnings_above_limit(Variable): + value_type = float + entity = Person + label = "weekly earnings above the Carer's Allowance earnings limit" + documentation = ( + "How far the person's weekly net earnings exceed the Carer's Allowance " + "earnings limit, or zero at or below it. The regulation tests the " + "earnings of the preceding week; annual earnings are spread evenly " + "over 52 weeks. A fraction of a penny is disregarded, as regulation 5 " + "of the Computation of Earnings Regulations requires when that favours " + "the claimant. Any excess means the person is gainfully employed; " + "where the Budget 2026 earnings taper applies, the allowance tapers " + "away on it instead of stopping." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="The Social Security (Invalid Care Allowance) Regulations 1976 reg. 8(1)", + href="https://www.legislation.gov.uk/uksi/1976/409/regulation/8", + ), + dict( + title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 5", + href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/5", + ), + ] + + def formula(person, period, parameters): + limit = parameters(period).gov.dwp.carers_allowance.earnings_limit + weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR + # Disregard a fraction of a penny; the tolerance keeps an exact + # number of pence from being knocked down by floating-point error. + weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 + return max_(0, weekly_in_pence - limit) diff --git a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py index b6e6b4b981..d2e8695003 100644 --- a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py @@ -31,9 +31,4 @@ class is_gainfully_employed_for_carers_allowance(Variable): ] def formula(person, period, parameters): - limit = parameters(period).gov.dwp.carers_allowance.earnings_limit - weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR - # Disregard a fraction of a penny; the tolerance keeps an exact - # number of pence from being knocked down by floating-point error. - weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 - return weekly_in_pence > limit + return person("carers_allowance_weekly_earnings_above_limit", period) > 0 From e5e8942ae4ab43150ca897e8ce5d387ce7c09470 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 12:37:48 -0400 Subject: [PATCH 08/29] Move older tests to the Budget 2026 rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five existing test files pinned values that Budget 2026 changes in current law: - the property basic rate and the savings rates from 2027-28 are 24%, not 22% (property income rate reforms, finance cost relief accounting, the savings starting rate path, and the ISA cash interest charge, which is at the savings basic rate in force for the year under SI 2026/1018 reg. 15); - the trading allowance is £3,000 from 2027-28, which sets relevant UK earnings for pension relief; - the Household Energy Payment is in household_benefits in 2026-27 but not in HBAI income. The finance cost relief bound now reads the property basic rate from the parameters and pins the uncapped case Hypothesis found. Co-Authored-By: Claude Opus 5.5 --- .../test_disable_simulated_benefits_carers.py | 15 +++++--- policyengine_uk/tests/test_isa_accounting.py | 16 +++++---- ...est_pension_annual_allowance_properties.py | 34 +++++++++++-------- .../tests/test_property_income_properties.py | 15 +++++--- .../test_property_income_rate_reforms.py | 6 ++-- .../test_savings_starting_rate_indexation.py | 21 ++++++------ 6 files changed, 65 insertions(+), 42 deletions(-) diff --git a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py index 388f2a4b73..e53ca5fb64 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py @@ -326,10 +326,17 @@ def simulate(regions_by_year): assert supplement[0] > 0 else: np.testing.assert_allclose(supplement, 0) - for name in ["household_benefits", "hbai_household_net_income"]: - np.testing.assert_allclose( - values(simulation, name, first + 1), 4_000 + supplement, err_msg=name - ) + # The Household Energy Payment (Budget 2026, England, 2026-27) is in + # household_benefits but not in HBAI income. + energy_payment = values(simulation, "household_energy_payment", first + 1) + np.testing.assert_allclose( + values(simulation, "household_benefits", first + 1), + 4_000 + supplement + energy_payment, + ) + np.testing.assert_allclose( + values(simulation, "hbai_household_net_income", first + 1), + 4_000 + supplement, + ) def test_a_reform_applied_after_a_move_is_set_reads_the_move(): diff --git a/policyengine_uk/tests/test_isa_accounting.py b/policyengine_uk/tests/test_isa_accounting.py index 7f733371f9..5378861755 100644 --- a/policyengine_uk/tests/test_isa_accounting.py +++ b/policyengine_uk/tests/test_isa_accounting.py @@ -65,7 +65,11 @@ def test_lisa_bonus_and_charge_reach_only_their_declared_fiscal_totals(): def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once(): - """From fiscal2027,100 of ISA interest gives22 tax and78 net income.""" + """From fiscal 2027, 100 of ISA interest gives 24 tax and 76 net income. + + The charge is at the savings basic rate in force for the year (SI + 2026/1018 reg. 15, new reg. 22A(3)), 24% from 2027-28 after Budget 2026. + """ situation = { "people": { "person": { @@ -83,16 +87,16 @@ def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once() baseline = Simulation(situation=situation) changed = Simulation(situation=with_return) assert changed.calculate("isa_cash_interest_flat_rate_charge", 2027).tolist() == [ - 22 + 24 ] for variable, expected in ( ("market_income", 100), ("capital_income", 100), ("household_market_income", 100), - ("gov_tax", 22), - ("household_tax", 22), - ("household_net_income", 78), - ("hbai_household_net_income", 78), + ("gov_tax", 24), + ("household_tax", 24), + ("household_net_income", 76), + ("hbai_household_net_income", 76), ("income_tax", 0), ): difference = changed.calculate(variable, 2027) - baseline.calculate( diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 2e6c991df9..4f28f279d5 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -165,15 +165,16 @@ def _situation(people, year, overrides=None): # 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 +# trading allowance 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)). The allowance is £1,000 from 2017-18 and £3,000 from 2027-28 +# (Budget 2026, paragraph 6.254). +TRADING_ALLOWANCE = {2020: 1_000, 2024: 1_000, 2026: 1_000, 2027: 3_000} -def _relevant_earnings(person): +def _relevant_earnings(person, year): profit = max(0, person["self_employment_income"]) - chargeable_profit = profit if profit > TRADING_ALLOWANCE else 0 + chargeable_profit = profit if profit > TRADING_ALLOWANCE[year] else 0 return person["employment_income"] + chargeable_profit @@ -194,18 +195,21 @@ def _calc(people, year, variables): return {v: np.array(sim.calculate(v, year), dtype=float) for v in variables} -def _base_contributions(people): - return [p["contribution_share"] * max(_relevant_earnings(p), 3_600) for p in people] +def _base_contributions(people, year): + return [ + p["contribution_share"] * max(_relevant_earnings(p, year), 3_600) + for p in people + ] @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) def test_relief_is_the_section_190_limit(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) out = _calc(people, year, ["pension_contributions_relief"]) basic_amount = 3_600 for i, person in enumerate(people): - earnings = _relevant_earnings(person) + earnings = _relevant_earnings(person, year) relief_at_source = person["personal_pension_contributions"] total = relief_at_source + person["employee_pension_contributions_reported"] limit = earnings + min(max(0, basic_amount - earnings), relief_at_source) @@ -223,7 +227,7 @@ def test_extra_contributions_never_raise_tax_by_more_than_the_charge( people, year, step ): # Two contribution levels, both within relevant earnings. - earnings = np.array([max(_relevant_earnings(p), 3_600) for p in people]) + earnings = np.array([max(_relevant_earnings(p, year), 3_600) for p in people]) low = np.array([min(p["contribution_share"], 1) for p in people]) * earnings high = low + step * (earnings - low) variables = ["income_tax", "personal_pension_contributions_tax"] @@ -318,7 +322,7 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste np.array(zero_sim.calculate("net_income", year), dtype=float) - restored_allowances, ) - earnings = np.array([_relevant_earnings(p) for p in people], dtype=float) + earnings = np.array([_relevant_earnings(p, year) for p in people], dtype=float) upper = np.minimum(earnings, taxable_income) employer = np.array([p["employer_pension_contributions"] for p in people]) at_allowance = np.clip(allowance - employer, 0, earnings) @@ -433,7 +437,7 @@ def _reference_ras_extension_eligibility( @PROPERTY_SETTINGS @given(reference_population_strategy(), st.sampled_from(YEARS)) def test_charge_matches_band_by_band_reference(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) sim = Simulation(situation=_situation(people, year)) chargeable = np.array( sim.calculate("pension_annual_allowance_chargeable_amount", year), dtype=float @@ -517,7 +521,7 @@ def test_charge_matches_band_by_band_reference(people, year): @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) # Compare the same pension/input amounts with zero, drawn, and larger # gifts, across UK and Scottish rates and mixed income sources. zero_gift = [{**p, "gift_aid": 0} for p in people] @@ -557,7 +561,7 @@ def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS), st.integers(0, 150_000)) def test_unused_allowance_reduces_chargeable_amount_one_for_one(people, year, extra): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) variables = [ "pension_annual_allowance_chargeable_amount", "personal_pension_contributions_tax", diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 5ea41141c3..4fa3853c96 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -23,7 +23,6 @@ YEAR = 2027 ALLOWANCE = 1_000 -PROPERTY_BASIC_RATE = 0.22 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 13_000, 30_000, 60_000, 110_000, 200_000] # Every third person is a Scottish taxpayer. SCOTTISH = [i % 3 == 2 for i in range(7)] @@ -198,7 +197,13 @@ def test_taxable_profit_matches_statutory_reference(sim, drawn): ).all() +# Finance costs relieved in full below the tax due: the reduction is the +# whole property basic rate on them (24% from 2027-28 after Budget 2026). +UNCAPPED_RELIEF = (1_471.59, 57_327.72, 0.0, 500.0, "below_profit", 0.45, 121.28) + + @SETTINGS +@example([UNCAPPED_RELIEF] * CASES) @given(cases) def test_relief_accounting(sim, drawn): r = run(sim, drawn) @@ -232,11 +237,11 @@ def test_relief_accounting(sim, drawn): ).all() # The reduction is at most the property basic rate on the costs, and at # most the Step 5 tax. + rate = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.property.basic( + f"{YEAR}-04-30" + ) assert (r["relief"] >= 0).all() - assert ( - r["relief"] - <= PROPERTY_BASIC_RATE * r["relievable"] + tolerance(r["relievable"]) - ).all() + assert (r["relief"] <= rate * r["relievable"] + tolerance(r["relievable"])).all() assert (r["relief"] <= r["pre_charges"] + tolerance(r["pre_charges"])).all() # What is not relieved is carried forward; on the allowance route this # year's costs are replaced by the allowance and only the amount brought diff --git a/policyengine_uk/tests/test_property_income_rate_reforms.py b/policyengine_uk/tests/test_property_income_rate_reforms.py index a32895ba8a..2ddeaa5e38 100644 --- a/policyengine_uk/tests/test_property_income_rate_reforms.py +++ b/policyengine_uk/tests/test_property_income_rate_reforms.py @@ -95,8 +95,10 @@ def test_from_2027_main_rate_reform_leaves_separate_property_rate_unchanged( sim = component_simulation( year, 10_000, 5_000, country=country, rates={UK_RATE.format(0): 0.21} ) + # 5,000 at the 24% property basic rate (22% from Budget 2025, 2 points + # more from Budget 2026). assert sim.calculate("property_income_tax", year)[0] == pytest.approx( - 1_100, abs=0.01 + 1_200, abs=0.01 ) @@ -142,7 +144,7 @@ def test_zero_property_income_has_zero_property_tax_under_reform(year): @pytest.mark.parametrize( - "year, expected", [(2025, 1_000), (2026, 1_000), (2027, 1_100), (2028, 1_100)] + "year, expected", [(2025, 1_000), (2026, 1_000), (2027, 1_200), (2028, 1_200)] ) def test_baseline_property_rates_retain_annual_2027_boundary(year, expected): sim = component_simulation(year, 10_000, 5_000) diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py index f5df01c766..ce6eb5eca9 100644 --- a/policyengine_uk/tests/test_savings_starting_rate_indexation.py +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -118,7 +118,8 @@ def savings_situation(): 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%. + # starting rate. The basic savings rate from 2027-28 is 24% (22% from + # Budget 2025 plus 2 points from Budget 2026). changes = { CPI: {"2030-09-01.2038-08-31": 0.02}, PA: {"2030-04-06.2034-04-05": 12_570}, @@ -132,10 +133,10 @@ def test_indexed_starting_rate_reaches_final_savings_income_tax(route): 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), + (2030, 5_000, 343.20), + (2031, 5_100, 319.20), + (2032, 5_210, 292.80), + (2033, 5_320, 266.40), ]: assert simulation.calculate("personal_allowance", year)[0] == 12_570 assert simulation.calculate("savings_allowance", year)[0] == 1_000 @@ -176,11 +177,11 @@ def test_dated_limit_override_resumes_indexation_from_the_changed_limit(route): 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%. + # £6,430 before the starting rate: (6,430 - limit) x 24%. for current, year, tax in [ - (simulation, 2031, 94.60), - (simulation, 2032, 68.20), - (control, 2032, 268.40), + (simulation, 2031, 103.20), + (simulation, 2032, 74.40), + (control, 2032, 292.80), ]: assert current.calculate("personal_allowance", year)[0] == 12_570 assert current.calculate("savings_allowance", year)[0] == 1_000 @@ -242,5 +243,5 @@ def test_reset_and_reprocessing_keep_the_changed_savings_indexation_path(): 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 + simulation.calculate(variable, 2032), [74.40], rtol=0, atol=0.001 ) From 948d15d53067b270d184fd5c0725b88d63d7c5d1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 13:30:27 -0400 Subject: [PATCH 09/29] Stop expecting zero Carer's Allowance over the 2030-31 limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 6 October 2030 the Budget 2026 taper pays the rate less the earnings above the limit in England and Wales, so a carer £1 over it keeps most of the allowance. The case now checks only that the earnings count as gainful employment; test_carers_allowance_earnings_taper.py checks the amounts. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/carers_allowance_earnings_limit_projection.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml index 9287fc4847..fd95fadbd8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit_projection.yaml @@ -67,5 +67,7 @@ care_hours: 40 country: ENGLAND output: + # Over the limit, but from 6 October 2030 the Budget 2026 taper pays the + # rate less the £1 above it in England and Wales, so the allowance is not + # zero. test_carers_allowance_earnings_taper.py checks the amounts. is_gainfully_employed_for_carers_allowance: true - carers_allowance: 0 From 064826e0078e474306b9790601be22ef6b770058 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:28:38 -0400 Subject: [PATCH 10/29] Put petrol and diesel at the OBR's 60.59p from 1 July 2027 The October 2026 EFO (paragraph 3.19) forecasts the 1 July 2027 rate at 60.59p: the 57.95p pre-cut rate uprated by RPI growth in the year to 2027 Q1. calculate_fuel_duty_rates.py now starts the schedule there (CUT_END_RATE) instead of uprating 57.95p by calendar 2026 RPI, which gave 59.75p on the March EFO and would give 60.03p on the October one. Each 1 April step from 2028 still applies the previous calendar year's RPI. This matches the duty path #2717 uses to derive road fuel volumes. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ab26-fuel-duty.changed.md | 2 +- .../fuel_duty/calculate_fuel_duty_rates.py | 68 ++++++++++++------- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 24 +++---- .../fuel_duty/petrol_and_diesel_schedule.yaml | 33 ++++----- .../tests/test_fuel_duty_schedule.py | 37 +++++----- 5 files changed, 96 insertions(+), 68 deletions(-) diff --git a/changelog.d/ab26-fuel-duty.changed.md b/changelog.d/ab26-fuel-duty.changed.md index c39e67a335..218491d422 100644 --- a/changelog.d/ab26-fuel-duty.changed.md +++ b/changelog.d/ab26-fuel-duty.changed.md @@ -1 +1 @@ -Keep the 5p fuel duty cut to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel return to 57.95p uprated by 2026 RPI growth, then rise by RPI each 1 April from 2028; LPG and natural gas return to their full rates. +Keep the 5p fuel duty cut to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI), then by RPI each 1 April from 2028; LPG and natural gas return to their full rates. diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index 8e34c85736..9b0f0bc595 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -4,13 +4,16 @@ Budget 2026 keeps the 5p cut to 1 July 2027. Its policy costing (page 13) says that "On 1 July 2027, fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel -duty rates will be uprated annually in line with RPI." This script takes the -57.95p pre-cut rate (Finance Act 2011 s. 19), applies 2026 RPI growth on -1 July 2027, then each previous calendar year's RPI growth on 1 April from -2028, using economic_assumptions/yoy_growth.yaml. It rounds to the nearest -hundredth of a penny and writes the results between the BEGIN and END -markers in petrol_and_diesel.yaml. Run it with --update whenever the RPI -forecast changes; test_fuel_duty_schedule.py fails until you do. +duty rates will be uprated annually in line with RPI." The 1 July 2027 +rate is the OBR's forecast in the EFO (CUT_END_RATE): 60.59p in October +2026, the 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI growth +in the year to 2027 Q1. This script applies each previous calendar year's +RPI growth to it on 1 April from 2028, using +economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of +a penny and writes the results between the BEGIN and END markers in +petrol_and_diesel.yaml. After a new EFO, set CUT_END_RATE to its fuel duty +forecast, and run it with --update whenever that or the RPI forecast +changes; test_fuel_duty_schedule.py fails until you do. Usage: uv run python calculate_fuel_duty_rates.py # print the steps @@ -27,11 +30,16 @@ PARAMETER_FILE = FUEL_DUTY_DIR / "petrol_and_diesel.yaml" YOY_GROWTH_FILE = FUEL_DUTY_DIR.parents[1] / "economic_assumptions" / "yoy_growth.yaml" -# The pre-cut rate the first step uprates, the date the cut ends, and the -# first and last 1 April steps written out. Later steps come from the +# The pre-cut rate, the date the cut ends and the OBR's rate from then, and +# the first and last 1 April steps written out. Later steps come from the # parameter's uprating metadata. BASE_DATE = "2011-03-23" CUT_END_DATE = "2027-07-01" +# OBR EFO October 2026, paragraph 3.19: "from 52.95 pence per litre to 60.59 +# pence per litre" on 1 July 2027. It is 57.95p times one plus RPI growth in +# 2027 Q1 on a year earlier (4.5565%, economy Table 1.7). +CUT_END_RATE = Decimal("0.6059") +EFO = "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/" FIRST_APRIL_YEAR = 2028 LAST_UPRATING_YEAR = 2031 @@ -61,20 +69,17 @@ def base_rate() -> Decimal: def forecast_steps() -> list: - """(date, rate, previous rate, RPI growth) for each forecast step.""" + """(date, rate, previous rate, RPI growth) for each forecast step. The + 1 July 2027 step is the OBR's rate, so it has no RPI growth of its own.""" growth = rpi_growth_by_year() - dates = [CUT_END_DATE] + [ - f"{year}-04-01" for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1) - ] - rate = base_rate() - steps = [] - for date in dates: - year = int(date[:4]) + rate = CUT_END_RATE + steps = [(CUT_END_DATE, rate, base_rate(), None)] + for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1): previous = rate rate = (previous * (1 + growth[year - 1])).quantize( PENNY_HUNDREDTH, rounding=ROUND_HALF_UP ) - steps.append((date, rate, previous, growth[year - 1])) + steps.append((f"{year}-04-01", rate, previous, growth[year - 1])) return steps @@ -83,19 +88,35 @@ def render(steps: list) -> str: forecast = rpi_series()["metadata"]["reference"][0] lines = [] for date, rate, previous, growth in steps: + if growth is None: + note = ( + f"OBR forecast. {previous} uprated by RPI growth in the year " + "to 2027 Q1." + ) + source = [ + " - title: OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)", + f" href: {EFO}", + ] + else: + note = ( + f"Forecast. {previous} uprated by {int(date[:4]) - 1} RPI " + f"growth of {growth * 100:.1f}%." + ) + source = [ + f" - title: {forecast['title']} (RPI forecast)", + f" href: {forecast['href']}", + ] lines += [ f" {date}:", f" value: {rate}", " metadata:", - f" note: Forecast. {previous} uprated by {int(date[:4]) - 1} RPI " - f"growth of {growth * 100:.1f}%.", + f" note: {note}", " reference:", " - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027)", f" href: {BUDGET_2026}", " - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028)", f" href: {COSTING_2026}", - f" - title: {forecast['title']} (RPI forecast)", - f" href: {forecast['href']}", + *source, ] return "\n".join(lines) + "\n" @@ -119,7 +140,8 @@ def main(): steps = forecast_steps() print(f"{BASE_DATE}: {base_rate()} (pre-cut rate)") for date, rate, previous, growth in steps: - print(f"{date}: {rate} ({previous} x (1 + {growth}))") + how = "OBR forecast" if growth is None else f"{previous} x (1 + {growth})" + print(f"{date}: {rate} ({how})") if args.update: update(steps) diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index 0092c4d11f..7fbfd22e39 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -104,20 +104,20 @@ values: # continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py 2027-07-01: - value: 0.5975 + value: 0.6059 metadata: - note: Forecast. 0.5795 uprated by 2026 RPI growth of 3.1%. + note: OBR forecast. 0.5795 uprated by RPI growth in the year to 2027 Q1. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - - title: OBR EFO March 2026 (Table A.1) (RPI forecast) - href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + - title: OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2028-04-01: - value: 0.6154 + value: 0.6241 metadata: - note: Forecast. 0.5975 uprated by 2027 RPI growth of 3.0%. + note: Forecast. 0.6059 uprated by 2027 RPI growth of 3.0%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -126,9 +126,9 @@ values: - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2029-04-01: - value: 0.6326 + value: 0.6416 metadata: - note: Forecast. 0.6154 uprated by 2028 RPI growth of 2.8%. + note: Forecast. 0.6241 uprated by 2028 RPI growth of 2.8%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -137,9 +137,9 @@ values: - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2030-04-01: - value: 0.6509 + value: 0.6602 metadata: - note: Forecast. 0.6326 uprated by 2029 RPI growth of 2.9%. + note: Forecast. 0.6416 uprated by 2029 RPI growth of 2.9%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -148,9 +148,9 @@ values: - title: OBR EFO March 2026 (Table A.1) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ 2031-04-01: - value: 0.6659 + value: 0.6754 metadata: - note: Forecast. 0.6509 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 0.6602 uprated by 2030 RPI growth of 2.3%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml index 3bdf69819a..62f079c072 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml @@ -2,12 +2,13 @@ # 6 April to 5 April, and the dated rates are day-weighted across it: # 2025-26: 365 days at 52.95p. # 2026-27: 365 days at 52.95p (Budget 2026 keeps the cut to 1 July 2027). -# 2027-28 (366 days): 86 at 52.95p (6 Apr to 30 Jun 2027), 275 at 59.75p -# (1 Jul 2027 to 31 Mar 2028) and 5 at 61.54p. -# 2028-29: 360 at 61.54p and 5 at 63.26p. -# 2029-30: 360 at 63.26p and 5 at 65.09p. -# 2030-31: 360 at 65.09p and 5 at 66.59p. -# Rates from 1 July 2027 are the RPI forecast in petrol_and_diesel.yaml. +# 2027-28 (366 days): 86 at 52.95p (6 Apr to 30 Jun 2027), 275 at 60.59p +# (1 Jul 2027 to 31 Mar 2028) and 5 at 62.41p. +# 2028-29: 360 at 62.41p and 5 at 64.16p. +# 2029-30: 360 at 64.16p and 5 at 66.02p. +# 2030-31: 360 at 66.02p and 5 at 67.54p. +# The 1 July 2027 rate is the OBR's forecast (EFO October 2026, paragraph +# 3.19); later rates are the RPI forecast in petrol_and_diesel.yaml. - name: Fuel duty for 2025-26 is 52.95p a litre all year period: 2025 absolute_error_margin: 0.01 @@ -31,8 +32,8 @@ petrol_litres: 600 diesel_litres: 400 output: - # 1000 * (0.5295 * 86 + 0.5975 * 275 + 0.6154 * 5) / 366 - fuel_duty: 581.766393442623 + # 1000 * (0.5295 * 86 + 0.6059 * 275 + 0.6241 * 5) / 366 + fuel_duty: 588.1967213114754 - name: Fuel duty for 2028-29 period: 2028 absolute_error_margin: 0.01 @@ -40,8 +41,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6154 * 360 + 0.6326 * 5) / 365 - fuel_duty: 615.6356164383562 + # 1000 * (0.6241 * 360 + 0.6416 * 5) / 365 + fuel_duty: 624.3397260273972 - name: Fuel duty for 2029-30 period: 2029 absolute_error_margin: 0.01 @@ -49,8 +50,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6326 * 360 + 0.6509 * 5) / 365 - fuel_duty: 632.8506849315068 + # 1000 * (0.6416 * 360 + 0.6602 * 5) / 365 + fuel_duty: 641.8547945205478 - name: Fuel duty for 2030-31 period: 2030 absolute_error_margin: 0.01 @@ -58,8 +59,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6509 * 360 + 0.6659 * 5) / 365 - fuel_duty: 651.1054794520548 + # 1000 * (0.6602 * 360 + 0.6754 * 5) / 365 + fuel_duty: 660.4082191780822 - name: Rural relief takes 5p a litre off the 2027-28 rate period: 2027 absolute_error_margin: 0.01 @@ -68,5 +69,5 @@ diesel_litres: 0 in_rural_fuel_duty_relief_area: true output: - # 581.766393442623 - 1000 * 0.05 - fuel_duty: 531.766393442623 + # 588.1967213114754 - 1000 * 0.05 + fuel_duty: 538.1967213114754 diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index 0062f88e96..8c122e019c 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -3,8 +3,9 @@ Each rate is keyed to the date it takes effect, and `fiscal_year_blend` day-weights the schedule across each model year (6 April to 5 April). -Budget 2026 keeps the 5p cut to 1 July 2027; rates from then are an RPI -forecast. Both are Budget 2026 policy, not yet law. +Budget 2026 keeps the 5p cut to 1 July 2027; the rate from then is the +OBR's forecast, and later rates an RPI forecast. Both are Budget 2026 policy, +not yet law. """ import datetime @@ -22,9 +23,12 @@ from policyengine_uk.system import system PATH = "gov.hmrc.fuel_duty.petrol_and_diesel" -# The 1 July 2027 step, then each 1 April. Each applies the previous calendar -# year's RPI growth. -FORECAST_DATES = ["2027-07-01"] + [f"{year}-04-01" for year in range(2028, 2032)] +# The 1 July 2027 step is the OBR's 60.59p (EFO October 2026, paragraph +# 3.19). Each 1 April step from 2028 applies the previous calendar year's RPI +# growth. +CUT_END_DATE = "2027-07-01" +CUT_END_RATE = 0.6059 +FORECAST_DATES = [f"{year}-04-01" for year in range(2028, 2032)] LAST_FORECAST_YEAR = 2031 CONVERTED_YEARS = range(2015, 2041) @@ -77,16 +81,16 @@ def test_scheduled_rate_on_each_date(date, rate): assert schedule(date) == pytest.approx(rate, abs=1e-12) -# Model year Y runs from 6 April Y to 5 April Y+1. Rates: 59.75p from 1 July -# 2027, then 61.54p (2028), 63.26p (2029), 65.09p (2030), 66.59p (2031). +# Model year Y runs from 6 April Y to 5 April Y+1. Rates: 60.59p from 1 July +# 2027, then 62.41p (2028), 64.16p (2029), 66.02p (2030), 67.54p (2031). HAND_COMPUTED = { 2025: 0.5295, 2026: 0.5295, # 6 Apr to 30 Jun 2027, then to 31 Mar 2028 (a leap year), then 5 days. - 2027: (0.5295 * 86 + 0.5975 * 275 + 0.6154 * 5) / 366, - 2028: (0.6154 * 360 + 0.6326 * 5) / 365, - 2029: (0.6326 * 360 + 0.6509 * 5) / 365, - 2030: (0.6509 * 360 + 0.6659 * 5) / 365, + 2027: (0.5295 * 86 + 0.6059 * 275 + 0.6241 * 5) / 366, + 2028: (0.6241 * 360 + 0.6416 * 5) / 365, + 2029: (0.6416 * 360 + 0.6602 * 5) / 365, + 2030: (0.6602 * 360 + 0.6754 * 5) / 365, } @@ -104,8 +108,8 @@ def test_forecast_steps_use_current_rpi_forecast(): parameters = raw_parameters() rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi schedule = parameters.gov.hmrc.fuel_duty.petrol_and_diesel - # The 57.95p pre-cut rate (Finance Act 2011 s. 19). - rate = Decimal("0.5795") + assert schedule(CUT_END_DATE) == pytest.approx(CUT_END_RATE, abs=1e-12) + rate = Decimal(str(CUT_END_RATE)) for date in FORECAST_DATES: day_before = ( datetime.date.fromisoformat(date) - datetime.timedelta(days=1) @@ -173,8 +177,8 @@ def fuel_duty_simulation(parameter_changes=None): def test_holding_52_95p_for_2027_removes_the_full_2027_28_rate_rise(): """Regression: calendar-year averaging diluted annual overrides. - The 2027-28 baseline averages 58.18p, so holding 52.95p for the whole - model year saves 5.23p a litre. + The 2027-28 baseline averages 58.82p, so holding 52.95p for the whole + model year saves 5.87p a litre. """ baseline = fuel_duty_simulation() reformed = fuel_duty_simulation({PATH: {"2027": 0.5295}}) @@ -182,7 +186,8 @@ def test_holding_52_95p_for_2027_removes_the_full_2027_28_rate_rise(): baseline.calculate("fuel_duty", 2027).values[0] - reformed.calculate("fuel_duty", 2027).values[0] ) - assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5295)) + # fuel_duty is float32: about £590 each side, so allow a tenth of a penny. + assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5295), abs=1e-3) @settings(max_examples=8, deadline=None) From 5bd68fd25dc1b484ced265ddc6b0bc8c52b32346 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:44:29 -0400 Subject: [PATCH 11/29] Take overlapping benefits off only the weeks the Carer's Allowance taper pays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In 2030-31 the taper covers 182 of the year's 365 days, so a carer over the earnings limit is paid in those weeks only. carers_allowance took a full year of overlapping benefits off that part-year amount: a carer earning £250 a week with £40 a week of State Pension got £0 instead of 182/365 x (rate - £46 - £40) x 52 = £234.54. The new carers_allowance_share_of_year_payable gives the share of the year the earnings condition lets the allowance be paid; the taper and the overlap deduction both use it. Adds the worked case, full-year and below-limit checks, and a property test of the weekly overlap rule over the paid weeks. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../test_carers_allowance_earnings_taper.py | 95 +++++++++++++++++++ .../variables/gov/dwp/carers_allowance.py | 6 +- .../dwp/carers_allowance_pre_couple_limit.py | 14 ++- .../carers_allowance_share_of_year_payable.py | 33 +++++++ 5 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 78041238c2..d97300303d 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days, and overlapping benefits such as State Pension reduce the allowance only in those weeks. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 02582db692..b71dbd2fd3 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -185,3 +185,98 @@ def test_earnings_plus_the_allowance_never_fall_as_earnings_rise(sims, drawn): tapers = np.isin(regions, TAPER_REGIONS)[:CASES] rise = total[CASES:] - total[:CASES] assert (rise[tapers] >= -0.011).all() + + +# Overlapping benefits come off only in the weeks the allowance is paid. A +# carer over the limit in 2030-31 is paid from 6 October 2030 only, so only +# that share of a weekly overlapping benefit reduces it. + + +def final_allowance(weekly_earnings, weekly_state_pension, year, region="NORTH_WEST"): + situation = carer(weekly_earnings, year, region) + person = situation["people"]["carer"] + person["age"] = {year: 70} + person["state_pension"] = {year: 52 * weekly_state_pension} + sim = Simulation(situation=situation) + return float(sim.calculate("carers_allowance", year)[0]) + + +def test_2030_31_takes_overlapping_benefits_off_only_the_taper_weeks(): + # £250 a week is £46 over the £204 limit; £40 a week of State Pension. + limit, rate = ca_parameters(START_YEAR) + excess = 250 - limit + expected = START_YEAR_SHARE * max(rate - excess - 40, 0) * 52 + assert final_allowance(250, 40, START_YEAR) == pytest.approx(expected, abs=0.05) + + +def test_full_taper_year_takes_off_the_whole_overlapping_benefit(): + limit, rate = ca_parameters(FULL_YEAR) + excess = 250 - limit + expected = max(rate - excess - 40, 0) * 52 + assert final_allowance(250, 40, FULL_YEAR) == pytest.approx(expected, abs=0.05) + + +def test_below_the_limit_2030_31_takes_off_the_whole_overlapping_benefit(): + limit, rate = ca_parameters(START_YEAR) + expected = max(rate - 40, 0) * 52 + assert final_allowance(limit - 1, 40, START_YEAR) == pytest.approx( + expected, abs=0.05 + ) + + +overlap_cases = st.lists( + st.tuples(weekly, st.floats(min_value=0, max_value=150)), + min_size=CASES, + max_size=CASES, +) + + +def overlap_situation(): + return { + "people": { + f"person_{i}": {"age": {START_YEAR: 70}, "care_hours": {START_YEAR: 40}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {START_YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def overlap_sim(): + return Simulation(situation=overlap_situation()) + + +@SETTINGS +@given(overlap_cases) +def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( + overlap_sim, drawn +): + earnings = np.array([e for e, _ in drawn]) + pension = np.array([p for _, p in drawn]) + overlap_sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + overlap_sim.set_input("state_pension", START_YEAR, 52 * pension) + overlap_sim.reset_calculations() + paid = overlap_sim.calculate("carers_allowance", START_YEAR).astype(float) + above = overlap_sim.calculate( + "carers_allowance_weekly_earnings_above_limit", START_YEAR + ).astype(float) + limit, rate = ca_parameters(START_YEAR) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(CASES)]) + tapers = np.isin(regions, TAPER_REGIONS) + # Weeks paid: all of them at or below the limit; above it, the taper + # weeks in England and Wales and none elsewhere. + share = np.where(above > 0, np.where(tapers, START_YEAR_SHARE, 0), 1) + weekly = np.where(above > 0, np.maximum(0, rate - above), rate) + expected = share * 52 * np.maximum(0, weekly - pension) + # Scotland pays Carer Support Payment instead. + england_wales_ni = regions != "SCOTLAND" + assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index b01082dbc8..4a0afbc65a 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -27,4 +27,8 @@ class carers_allowance(Variable): def formula(person, period, parameters): pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) - return max_(pre_overlap - overlapping, 0) + # The overlap rule reduces the allowance only in weeks it is paid, so + # in a year the earnings taper covers in part, only that share of + # the overlapping benefits comes off. + share = person("carers_allowance_share_of_year_payable", period) + return max_(pre_overlap - share * overlapping, 0) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py index 97a9bd67d5..da943a1548 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py @@ -34,15 +34,13 @@ def formula(person, period, parameters): ) # Budget 2026 (para 6.13) replaces that cliff edge in England and # Wales with a taper: the weekly allowance less taper.rate for each - # pound of weekly earnings above the limit. taper.applies is the - # share of the year in which it does, so a year it starts in part - # pays the tapered amount for that share of its weeks. - taper = ca.earnings_taper - country = person.household("country", period).decode_to_str() - tapers = np.isin(country, list(taper.countries)) * taper.applies + # pound of weekly earnings above the limit. A year the taper starts + # in part pays the tapered amount for the share of its weeks that + # carers_allowance_share_of_year_payable gives. excess = person("carers_allowance_weekly_earnings_above_limit", period) - tapered = max_(0, ca.rate - taper.rate * excess) - weekly = where(gainfully_employed, tapers * tapered, ca.rate) + tapered = max_(0, ca.rate - ca.earnings_taper.rate * excess) + share = person("carers_allowance_share_of_year_payable", period) + weekly = share * where(gainfully_employed, tapered, ca.rate) eligible = ( ~(in_scotland & csp_replaces_ca) & (meets_work_condition | receives_ca) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py b/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py new file mode 100644 index 0000000000..6cc6f4e2b3 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class carers_allowance_share_of_year_payable(Variable): + value_type = float + entity = Person + label = "share of the year in which Carer's Allowance can be paid" + documentation = ( + "The share of the year's weeks in which the earnings condition lets " + "Carer's Allowance be paid. It is 1 for anyone at or below the " + "earnings limit. Above it, the allowance stops in full except where " + "the Budget 2026 earnings taper applies, so it is the share of the " + "year the taper covers in England and Wales (182/365 in 2030-31 on " + "the model's 6 October 2030 start, then 1) and 0 elsewhere. " + "carers_allowance deducts overlapping benefits only for this share " + "of the year, because the weekly overlap rule applies only in weeks " + "the allowance is paid." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", + "https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=16", + ) + + def formula(person, period, parameters): + taper = parameters(period).gov.dwp.carers_allowance.earnings_taper + country = person.household("country", period).decode_to_str() + tapers = np.isin(country, list(taper.countries)) * taper.applies + gainfully_employed = person( + "is_gainfully_employed_for_carers_allowance", period + ) + return where(gainfully_employed, tapers, 1) From 805bf0ea3a0de32f261f09dcb09e214212dcdfd8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:44:29 -0400 Subject: [PATCH 12/29] Bound the finance cost relief rise only where the old relief was uncapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relief is limited to the tax left after earlier reductions. Where that capped the Budget 2025 relief, the 2-point rise in the rates raises the cap too, so the relief can rise by more than 2% of the costs: with £7,800 of other tax credits it goes from £86 to £240. The property test now applies the 2-point bound only where the old relief was its full 22%, and a worked test pins the capped case. Co-Authored-By: Claude Opus 5.5 --- ..._2026_savings_property_rates_properties.py | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index e0397937fd..e154385690 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -11,8 +11,11 @@ income stays on the Scottish rates; - savings tax rises by exactly 2% of the savings income in the basic, higher and additional bands, in every UK country; -- finance cost relief, which follows the property basic rate, rises by at - most 2% of the costs relieved, and never falls. +- finance cost relief, which follows the property basic rate, never falls, + and rises by at most 2% of the costs relieved wherever the Budget 2025 + relief was its full 22%. Where the tax left after earlier reductions + capped it, the relief can rise by more: the higher rates leave more tax + for it (test_capped_finance_cost_relief_can_rise_by_more_than_two_points). """ import numpy as np @@ -156,4 +159,35 @@ def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): cap = RISE * law["property_finance_costs_relieved"] tol = tolerance(law["property_finance_cost_relief"], cap) assert (rise >= -tol).all() - assert (rise <= cap + tol).all() + full_before = 0.22 * before["property_finance_costs_relieved"] + uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol + assert (rise <= cap + tol)[uncapped].all() + + +def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): + """£7,800 of earlier tax credits leave £86 of tax under the Budget 2025 + rates, so the relief on £1,000 of finance costs is £86, not £220. The + Budget 2026 rates raise tax before reductions by £400, to £8,286, leaving + £486: the relief is its full 24%, £240, a rise of £154.""" + situation = { + "people": { + "person": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 30_000}, + "property_income": {YEAR: 20_000}, + "property_finance_costs": {YEAR: 1_000}, + "other_tax_credits": {YEAR: 7_800}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], "region": {YEAR: "LONDON"}} + }, + } + law = Simulation(situation=situation) + before = Simulation(situation=situation, reform=BUDGET_2025_RATES) + relief = "property_finance_cost_relief" + assert float(before.calculate(relief, YEAR)[0]) == pytest.approx(86, abs=0.01) + assert float(law.calculate(relief, YEAR)[0]) == pytest.approx(240, abs=0.01) + assert float(before.calculate("income_tax", YEAR)[0]) == pytest.approx(0, abs=0.01) + assert float(law.calculate("income_tax", YEAR)[0]) == pytest.approx(246, abs=0.01) From d43e0151e8862fcc4f990504b922d70c9ffe85aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:44:29 -0400 Subject: [PATCH 13/29] Count the Household Energy Payment in HBAI income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HBAI counted the 2022 £150 Council Tax Rebate and the Energy Bills Support Scheme as miscellaneous income (HBAI quality and methodology report FYE 2023), so the tests' premise that the rebate stayed out of HBAI was wrong. The payment is the same kind of support to the same bands, so it now raises hbai_household_net_income by £100. The variable and docs also say who it goes to (the householder liable for council tax on their main home, most exempt households included) and what the model cannot see: households whose landlord is liable are paid. Co-Authored-By: Claude Opus 5.5 --- .../ab26-household-energy-payment.added.md | 2 +- .../gov/treasury/cost-of-living-payments.md | 25 +++++++++++++------ .../test_disable_simulated_benefits_carers.py | 4 +-- ...ousehold_energy_payment_income_measures.py | 15 +++++------ .../household_energy_payment.py | 13 +++++++--- .../income/hbai_household_net_income.py | 5 ++++ 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/changelog.d/ab26-household-energy-payment.added.md b/changelog.d/ab26-household-energy-payment.added.md index 29decaf5af..0b6095b0be 100644 --- a/changelog.d/ab26-household-energy-payment.added.md +++ b/changelog.d/ab26-household-energy-payment.added.md @@ -1 +1 @@ -Add the Household Energy Payment announced at Budget 2026: £100 in 2026-27 for households in council tax bands A to D in England, counted in household benefits and government spending. +Add the Household Energy Payment announced at Budget 2026: £100 in 2026-27 for households in council tax bands A to D in England, counted in household benefits, government spending and HBAI income (as HBAI counted the 2022 Council Tax Rebate). diff --git a/docs/book/programs/gov/treasury/cost-of-living-payments.md b/docs/book/programs/gov/treasury/cost-of-living-payments.md index d2f99219f6..3c2d18ccd8 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -31,8 +31,8 @@ PolicyEngine models these via: ## Household Energy Payment (2026-27) Budget 2026 (paragraph 6.24) announced a one-off £100 Household Energy -Payment for households in council tax bands A to D in England, delivered -by local authorities from January 2027. PolicyEngine models it as +Payment for households in council tax bands A to D in England, paid into +bank accounts by local authorities from January 2027. PolicyEngine models it as `household_energy_payment` (Household, YEAR), with parameters at `parameters/gov/treasury/household_energy_payment/`: @@ -42,11 +42,22 @@ by local authorities from January 2027. PolicyEngine models it as - `countries.yaml`: qualifying countries (England) It counts in `household_benefits` and `gov_spending`, as the Energy Bills -Rebate does. The devolved governments receive a Barnett share instead, -which the model does not allocate, and the £100 million of further -local-authority funding for targeted support is not modelled. -`council_tax_band` defaults to band D, so a household with no band -recorded qualifies. +Rebate does, and in `hbai_household_net_income`: HBAI counted the 2022 +Council Tax Rebate and the Energy Bills Support Scheme as miscellaneous +income ([HBAI quality and methodology report FYE 2023](https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2023/households-below-average-income-series-quality-and-methodology-information-report-fye-2023)). +The devolved governments receive a Barnett share instead, which the model +does not allocate, and the £100 million of further local-authority funding +for targeted support is not modelled. + +The payment goes to the householder [liable for council tax on their main +home](https://www.gov.uk/government/news/what-the-autumn-budget-2026-means-for-your-energy-bills), +including most households exempt from council tax; full criteria are to +follow. The model pays every household in a qualifying band. Survey data +hold only main homes, so the second homes and empty properties the scheme +excludes do not arise. Households whose landlord is liable (council tax in +the rent) cannot be identified, so they are paid, which slightly overstates +recipients. `council_tax_band` defaults to band D, so a household with no +band recorded qualifies. ## Cost-of-Living Payments (2022-23 and 2023-24) diff --git a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py index e53ca5fb64..a4c9c6410f 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py @@ -327,7 +327,7 @@ def simulate(regions_by_year): else: np.testing.assert_allclose(supplement, 0) # The Household Energy Payment (Budget 2026, England, 2026-27) is in - # household_benefits but not in HBAI income. + # household_benefits and HBAI income. energy_payment = values(simulation, "household_energy_payment", first + 1) np.testing.assert_allclose( values(simulation, "household_benefits", first + 1), @@ -335,7 +335,7 @@ def simulate(regions_by_year): ) np.testing.assert_allclose( values(simulation, "hbai_household_net_income", first + 1), - 4_000 + supplement, + 4_000 + supplement + energy_payment, ) diff --git a/policyengine_uk/tests/test_household_energy_payment_income_measures.py b/policyengine_uk/tests/test_household_energy_payment_income_measures.py index 2a03523657..8ac05ccfd5 100644 --- a/policyengine_uk/tests/test_household_energy_payment_income_measures.py +++ b/policyengine_uk/tests/test_household_energy_payment_income_measures.py @@ -1,8 +1,9 @@ """The Household Energy Payment in the income measures. The £100 payment (Budget 2026, paragraph 6.24) raises household net income -but, like the 2022 council tax rebate (energy_bills_rebate), is not in -hbai_household_net_income, so it moves no poverty or inequality figure. +and HBAI income alike: HBAI counted the 2022 Council Tax Rebate and the +Energy Bills Support Scheme, the same kind of one-off support, as +miscellaneous income (HBAI quality and methodology report FYE 2023). """ import pytest @@ -38,13 +39,9 @@ def measures(reform=None): } -def test_payment_raises_net_income_but_not_hbai_income(): +def test_payment_raises_net_income_and_hbai_income_by_100(): law, without = measures(), measures(WITHOUT_PAYMENT) assert law["household_energy_payment"] == 100 assert without["household_energy_payment"] == 0 - assert law["household_net_income"] - without["household_net_income"] == ( - pytest.approx(100, abs=0.01) - ) - assert law["hbai_household_net_income"] == pytest.approx( - without["hbai_household_net_income"], abs=0.01 - ) + for income in ("household_net_income", "hbai_household_net_income"): + assert law[income] - without[income] == pytest.approx(100, abs=0.01) diff --git a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py index ab7012521e..02baf84fd1 100644 --- a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py +++ b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py @@ -5,9 +5,15 @@ class household_energy_payment(Variable): label = "Household Energy Payment" documentation = ( "One-off payment to households in the qualifying council tax bands and " - "countries, delivered by local authorities through the council tax " - "system. Budget 2026 sets £100 for bands A to D in England, from " - "January 2027." + "countries, administered by local authorities and paid into bank " + "accounts. Budget 2026 sets £100 for bands A to D in England, from " + "January 2027. It goes to the householder liable for council tax on " + "their main home, including most households exempt from council tax. " + "The model pays every household in a qualifying band: survey data " + "hold only main homes, so second homes and empty properties do not " + "arise, but households whose landlord is liable (council tax in the " + "rent) cannot be identified and are paid, which slightly overstates " + "recipients." ) entity = Household definition_period = YEAR @@ -16,6 +22,7 @@ class household_energy_payment(Variable): reference = [ "https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117", "https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html", + "https://www.gov.uk/government/news/what-the-autumn-budget-2026-means-for-your-energy-bills", ] def formula(household, period, parameters): diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 604ec70078..4e157b4073 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -63,6 +63,11 @@ # HBAI classifies the Warm Home Discount as miscellaneous income: # 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 "warm_home_discount", + # HBAI counted the 2022 £150 Council Tax Rebate and the Energy Bills + # Support Scheme as miscellaneous income (HBAI quality and methodology + # report FYE 2023). The Household Energy Payment is the same kind of + # one-off support to the same council tax bands, so it counts too. + "household_energy_payment", # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] From 9521429ec20077b23c969aeea030ee487fb40282 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:44:29 -0400 Subject: [PATCH 14/29] Uprate LPG and natural gas duty by RPI from 1 July 2027 Budget 2026 restores fuel duty on 1 July 2027 "uprated in line with RPI", then each 1 April from 2028 (costings page 13), and LPG and natural gas take the proportionate cut to the same date. Their full rates came back unuprated. calculate_fuel_duty_rates.py now writes all three schedules: the full rate times RPI growth to 2027 Q1 (4.5565%, which gives the OBR's 60.59p for petrol and diesel) on 1 July 2027, then the previous calendar year's RPI each April, with RPI uprating metadata after 2031. LPG is 33.05p and natural gas 25.83p from 1 July 2027. Also corrects the petrol schedule's comment, which still described the calendar-2026 step. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ab26-fuel-duty.changed.md | 2 +- .../fuel_duty/calculate_fuel_duty_rates.py | 117 +++++++++++------- .../parameters/gov/hmrc/fuel_duty/lpg.yaml | 79 +++++++++++- .../gov/hmrc/fuel_duty/natural_gas.yaml | 79 +++++++++++- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 12 +- .../gov/hmrc/fuel_duty/road_fuel_gases.yaml | 26 +++- .../tests/test_fuel_duty_schedule.py | 29 +++++ 7 files changed, 282 insertions(+), 62 deletions(-) diff --git a/changelog.d/ab26-fuel-duty.changed.md b/changelog.d/ab26-fuel-duty.changed.md index 218491d422..093c39325c 100644 --- a/changelog.d/ab26-fuel-duty.changed.md +++ b/changelog.d/ab26-fuel-duty.changed.md @@ -1 +1 @@ -Keep the 5p fuel duty cut to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI), then by RPI each 1 April from 2028; LPG and natural gas return to their full rates. +Keep the 5p fuel duty cut, and the proportionate cut for LPG and natural gas, to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI growth to 2027 Q1), and LPG and natural gas return to their full rates with the same uprating; all three then rise by RPI each 1 April from 2028. diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index 9b0f0bc595..d26493ba40 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -1,19 +1,22 @@ """ -Generates the forecast RPI uprating steps in petrol_and_diesel.yaml. +Generates the forecast RPI uprating steps in petrol_and_diesel.yaml, lpg.yaml +and natural_gas.yaml. Budget 2026 keeps the 5p cut to 1 July 2027. Its policy costing (page 13) says that "On 1 July 2027, fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel -duty rates will be uprated annually in line with RPI." The 1 July 2027 -rate is the OBR's forecast in the EFO (CUT_END_RATE): 60.59p in October -2026, the 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI growth -in the year to 2027 Q1. This script applies each previous calendar year's -RPI growth to it on 1 April from 2028, using -economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of -a penny and writes the results between the BEGIN and END markers in -petrol_and_diesel.yaml. After a new EFO, set CUT_END_RATE to its fuel duty -forecast, and run it with --update whenever that or the RPI forecast -changes; test_fuel_duty_schedule.py fails until you do. +duty rates will be uprated annually in line with RPI." The OBR forecasts +the 1 July 2027 petrol and diesel rate (CUT_END_RATE: 60.59p in the October +2026 EFO): the 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI +growth in the year to 2027 Q1 (CUT_END_RPI_GROWTH). LPG and natural gas +take the proportionate cut to the same date, so their full rates get the +same uprating then. From 1 April 2028 the script applies each previous +calendar year's RPI growth, using economic_assumptions/yoy_growth.yaml. It +rounds to the nearest hundredth of a penny and writes the results between +the BEGIN and END markers in each file. After a new EFO, set CUT_END_RATE +and CUT_END_RPI_GROWTH to its forecast, and run it with --update whenever +they or the RPI forecast change; test_fuel_duty_schedule.py fails until you +do. Usage: uv run python calculate_fuel_duty_rates.py # print the steps @@ -27,18 +30,22 @@ import yaml FUEL_DUTY_DIR = Path(__file__).resolve().parent -PARAMETER_FILE = FUEL_DUTY_DIR / "petrol_and_diesel.yaml" YOY_GROWTH_FILE = FUEL_DUTY_DIR.parents[1] / "economic_assumptions" / "yoy_growth.yaml" -# The pre-cut rate, the date the cut ends and the OBR's rate from then, and -# the first and last 1 April steps written out. Later steps come from the -# parameter's uprating metadata. -BASE_DATE = "2011-03-23" +# Each parameter file and the date of its full (pre-cut) rate. +FUELS = { + "petrol_and_diesel.yaml": "2011-03-23", + "lpg.yaml": "2021-01-01", + "natural_gas.yaml": "2021-01-01", +} +# The date the cut ends, and the first and last 1 April steps written out. +# Later steps come from each parameter's uprating metadata. CUT_END_DATE = "2027-07-01" # OBR EFO October 2026, paragraph 3.19: "from 52.95 pence per litre to 60.59 # pence per litre" on 1 July 2027. It is 57.95p times one plus RPI growth in # 2027 Q1 on a year earlier (4.5565%, economy Table 1.7). CUT_END_RATE = Decimal("0.6059") +CUT_END_RPI_GROWTH = Decimal("0.045565") EFO = "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/" FIRST_APRIL_YEAR = 2028 LAST_UPRATING_YEAR = 2031 @@ -61,40 +68,55 @@ def rpi_growth_by_year() -> dict: return {int(str(date)[:4]): Decimal(str(value)) for date, value in values.items()} -def base_rate() -> Decimal: - with open(PARAMETER_FILE) as f: +def base_rate(fuel: str) -> Decimal: + with open(FUEL_DUTY_DIR / fuel) as f: values = yaml.safe_load(f)["values"] - entry = {str(date): value for date, value in values.items()}[BASE_DATE] + entry = {str(date): value for date, value in values.items()}[FUELS[fuel]] return Decimal(str(entry["value"] if isinstance(entry, dict) else entry)) -def forecast_steps() -> list: +def uprate(rate: Decimal, growth: Decimal) -> Decimal: + return (rate * (1 + growth)).quantize(PENNY_HUNDREDTH, rounding=ROUND_HALF_UP) + + +def forecast_steps(fuel: str) -> list: """(date, rate, previous rate, RPI growth) for each forecast step. The - 1 July 2027 step is the OBR's rate, so it has no RPI growth of its own.""" + 1 July 2027 step applies the OBR's RPI growth to 2027 Q1, so it has no + calendar-year growth of its own.""" growth = rpi_growth_by_year() - rate = CUT_END_RATE - steps = [(CUT_END_DATE, rate, base_rate(), None)] + base = base_rate(fuel) + rate = uprate(base, CUT_END_RPI_GROWTH) + steps = [(CUT_END_DATE, rate, base, None)] for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1): previous = rate - rate = (previous * (1 + growth[year - 1])).quantize( - PENNY_HUNDREDTH, rounding=ROUND_HALF_UP - ) + rate = uprate(previous, growth[year - 1]) steps.append((f"{year}-04-01", rate, previous, growth[year - 1])) return steps -def render(steps: list) -> str: +def render(fuel: str, steps: list) -> str: # Cite the forecast that yoy_growth.yaml itself cites for RPI. forecast = rpi_series()["metadata"]["reference"][0] lines = [] for date, rate, previous, growth in steps: if growth is None: - note = ( - f"OBR forecast. {previous} uprated by RPI growth in the year " - "to 2027 Q1." - ) + pct = f"{CUT_END_RPI_GROWTH * 100:.2f}%" + if fuel == "petrol_and_diesel.yaml": + note = ( + f"OBR forecast. {previous} uprated by RPI growth in the " + f"year to 2027 Q1 ({pct})." + ) + title = "OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)" + else: + note = ( + f"Forecast. {previous} uprated by RPI growth in the year " + f"to 2027 Q1 ({pct}), as the OBR uprates petrol and diesel." + ) + title = ( + "OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027)" + ) source = [ - " - title: OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)", + f" - title: {title}", f" href: {EFO}", ] else: @@ -121,12 +143,13 @@ def render(steps: list) -> str: return "\n".join(lines) + "\n" -def update(steps: list) -> None: - content = PARAMETER_FILE.read_text() +def update(fuel: str, steps: list) -> None: + path = FUEL_DUTY_DIR / fuel + content = path.read_text() start = content.index(BEGIN) + len(BEGIN) stop = content.index(END) - PARAMETER_FILE.write_text(content[:start] + render(steps) + content[stop:]) - print(f"Updated {PARAMETER_FILE}") + path.write_text(content[:start] + render(fuel, steps) + content[stop:]) + print(f"Updated {path}") def main(): @@ -134,16 +157,22 @@ def main(): parser.add_argument( "--update", action="store_true", - help="Rewrite the forecast block in petrol_and_diesel.yaml", + help="Rewrite the forecast block in each fuel duty parameter file", ) args = parser.parse_args() - steps = forecast_steps() - print(f"{BASE_DATE}: {base_rate()} (pre-cut rate)") - for date, rate, previous, growth in steps: - how = "OBR forecast" if growth is None else f"{previous} x (1 + {growth})" - print(f"{date}: {rate} ({how})") - if args.update: - update(steps) + for fuel in FUELS: + steps = forecast_steps(fuel) + if fuel == "petrol_and_diesel.yaml" and steps[0][1] != CUT_END_RATE: + raise ValueError( + f"CUT_END_RPI_GROWTH gives {steps[0][1]} on {CUT_END_DATE}, not " + f"the OBR's {CUT_END_RATE}" + ) + print(f"{fuel}: {FUELS[fuel]} {base_rate(fuel)} (full rate)") + for date, rate, previous, growth in steps: + how = f"{previous} x (1 + {growth or CUT_END_RPI_GROWTH})" + print(f" {date}: {rate} ({how})") + if args.update: + update(fuel, steps) if __name__ == "__main__": diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index d135be7f05..55b6865664 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -1,8 +1,70 @@ description: The UK levies road fuel duty on LPG at this rate per kilogram. values: - '2021-01-01': 0.3161 - '2022-03-23': 0.2888 - '2027-07-01': 0.3161 + 2021-01-01: 0.3161 + 2022-03-23: 0.2888 + # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; + # policy costings page 13). The full rate then returns uprated by RPI + # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel, + # and rises by the previous calendar year's RPI growth each 1 April from + # 2028. That is policy, not law. After the last entry, the uprating + # metadata below continues the same April cadence. + # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py + 2027-07-01: + value: 0.3305 + metadata: + note: Forecast. 0.3161 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2028-04-01: + value: 0.3404 + metadata: + note: Forecast. 0.3305 uprated by 2027 RPI growth of 3.0%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2029-04-01: + value: 0.3499 + metadata: + note: Forecast. 0.3404 uprated by 2028 RPI growth of 2.8%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2030-04-01: + value: 0.3600 + metadata: + note: Forecast. 0.3499 uprated by 2029 RPI growth of 2.9%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2031-04-01: + value: 0.3683 + metadata: + note: Forecast. 0.3600 uprated by 2030 RPI growth of 2.3%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + # END RPI forecast generated by calculate_fuel_duty_rates.py metadata: unit: currency-GBP period: year @@ -17,3 +79,14 @@ metadata: - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true + # After 2031-04-01, each 1 April applies the previous calendar year's RPI + # growth. + uprating: + parameter: gov.economic_assumptions.indices.obr.rpi + at_defined_interval: + start: "0001-01-01" + end: "0002-01-01" + enactment: "0003-04-01" + rounding: + type: nearest + interval: 0.0001 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index 54954c6f14..7b97f1f231 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -1,8 +1,70 @@ description: The UK levies road fuel duty on natural gas at this rate per kilogram. values: - '2021-01-01': 0.247 - '2022-03-23': 0.2257 - '2027-07-01': 0.247 + 2021-01-01: 0.247 + 2022-03-23: 0.2257 + # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; + # policy costings page 13). The full rate then returns uprated by RPI + # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel, + # and rises by the previous calendar year's RPI growth each 1 April from + # 2028. That is policy, not law. After the last entry, the uprating + # metadata below continues the same April cadence. + # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py + 2027-07-01: + value: 0.2583 + metadata: + note: Forecast. 0.247 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2028-04-01: + value: 0.2660 + metadata: + note: Forecast. 0.2583 uprated by 2027 RPI growth of 3.0%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2029-04-01: + value: 0.2734 + metadata: + note: Forecast. 0.2660 uprated by 2028 RPI growth of 2.8%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2030-04-01: + value: 0.2813 + metadata: + note: Forecast. 0.2734 uprated by 2029 RPI growth of 2.9%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + 2031-04-01: + value: 0.2878 + metadata: + note: Forecast. 0.2813 uprated by 2030 RPI growth of 2.3%. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO March 2026 (Table A.1) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + # END RPI forecast generated by calculate_fuel_duty_rates.py metadata: unit: currency-GBP period: year @@ -17,3 +79,14 @@ metadata: - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true + # After 2031-04-01, each 1 April applies the previous calendar year's RPI + # growth. + uprating: + parameter: gov.economic_assumptions.indices.obr.rpi + at_defined_interval: + start: "0001-01-01" + end: "0002-01-01" + enactment: "0003-04-01" + rounding: + type: nearest + interval: 0.0001 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index 7fbfd22e39..3900a58298 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -94,11 +94,11 @@ values: # Forecast from 1 July 2027. Budget 2026's costing: "On 1 July 2027, fuel # duty rates will increase by 5ppl and will be uprated in line with the # Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be - # uprated annually in line with RPI." That is policy, not law. No official - # source names the RPI measure. The 1 July 2027 step takes the 57.95p - # pre-cut rate (Finance Act 2011 s. 19) and applies 2026 RPI growth, the - # increase deferred from April 2027; each 1 April from 2028 applies the - # previous calendar year's RPI growth from + # uprated annually in line with RPI." That is policy, not law. The 1 July + # 2027 step is the OBR's forecast (EFO October 2026, paragraph 3.19): the + # 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI growth in the + # year to 2027 Q1, 60.59p. Each 1 April from 2028 applies the previous + # calendar year's RPI growth from # gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest # hundredth of a penny. After the last entry, the uprating metadata below # continues the same April cadence. @@ -106,7 +106,7 @@ values: 2027-07-01: value: 0.6059 metadata: - note: OBR forecast. 0.5795 uprated by RPI growth in the year to 2027 Q1. + note: OBR forecast. 0.5795 uprated by RPI growth in the year to 2027 Q1 (4.56%). reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml index 10ffa1b2fe..054e58576c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml @@ -1,6 +1,10 @@ # Gas quantities are kilograms. Budget 2026 keeps the 5p-equivalent cut to # 1 July 2027 (paragraph 6.211; policy costings page 13), so FY2026 is all at -# the cut rates and FY2027 weights are 86/280 days. +# the cut rates. On 1 July 2027 the full rates return uprated by RPI growth in +# the year to 2027 Q1 (4.5565%): LPG 33.05p, natural gas 25.83p a kilogram. +# Each 1 April from 2028 applies the previous calendar year's RPI growth: LPG +# 34.04p and natural gas 26.60p in 2028, then 34.99p and 27.34p in 2029. +# FY2027 (366 days) weights are 86, 275 and 5 days. # Rural relief only reduces the petrol/diesel component. - name: No road fuel gas consumption period: 2026 @@ -44,7 +48,7 @@ output: # 1000 * (0.2888 + 0.2257) fuel_duty: 514.5 -- name: Fiscal 2027 gas rates return to the full rates on 1 July 2027 +- name: Fiscal 2027 gas rates return to the RPI-uprated full rates on 1 July 2027 period: 2027 absolute_error_margin: 0.01 input: @@ -53,6 +57,18 @@ petrol_litres: 0 diesel_litres: 0 output: - # 1000 * (0.2888 * 86 + 0.3161 * 280) / 366 - # + 1000 * (0.2257 * 86 + 0.2470 * 280) / 366 - fuel_duty: 551.6803278688524 + # 1000 * (0.2888 * 86 + 0.3305 * 275 + 0.3404 * 5) / 366 + # + 1000 * (0.2257 * 86 + 0.2583 * 275 + 0.2660 * 5) / 366 + fuel_duty: 571.5819672131148 +- name: Fiscal 2028 gas rates rise by 2027 RPI on 1 April 2028 + period: 2028 + absolute_error_margin: 0.01 + input: + lpg_kg: 1000 + natural_gas_kg: 1000 + petrol_litres: 0 + diesel_litres: 0 + output: + # 1000 * (0.3404 * 360 + 0.3499 * 5) / 365 + # + 1000 * (0.2660 * 360 + 0.2734 * 5) / 365 + fuel_duty: 606.6315068493151 diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index 8c122e019c..6a275e32d0 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -123,6 +123,35 @@ def test_forecast_steps_use_current_rpi_forecast(): assert schedule(day_before) != schedule(date) +@pytest.mark.parametrize( + "fuel, full_rate", [("lpg", "0.3161"), ("natural_gas", "0.2470")] +) +def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate): + """LPG and natural gas take the proportionate cut to 1 July 2027 (Budget + 2026, paragraph 6.211; costings page 13). Their full rates then get the + OBR's 1 July uprating for petrol and diesel, 57.95p to 60.59p, and RPI + each 1 April from 2028.""" + parameters = raw_parameters() + rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi + schedule = getattr(parameters.gov.hmrc.fuel_duty, fuel) + july_growth = Decimal("0.045565") + assert (Decimal("0.5795") * (1 + july_growth)).quantize( + Decimal("0.0001"), ROUND_HALF_UP + ) == Decimal(str(CUT_END_RATE)) + rate = (Decimal(full_rate) * (1 + july_growth)).quantize( + Decimal("0.0001"), ROUND_HALF_UP + ) + assert schedule("2027-06-30") < float(full_rate) + assert schedule(CUT_END_DATE) == pytest.approx(float(rate), abs=1e-12) + for date in FORECAST_DATES: + growth = Decimal(str(rpi(f"{int(date[:4]) - 1}-01-01"))) + rate = (rate * (1 + growth)).quantize(Decimal("0.0001"), ROUND_HALF_UP) + assert schedule(date) == pytest.approx(float(rate), abs=1e-12), ( + f"{fuel} {date} is stale against the RPI forecast; run " + "calculate_fuel_duty_rates.py --update" + ) + + def test_uprating_continues_each_april_after_the_written_steps(): schedule = dated_schedule() rpi = system.parameters.baseline.gov.economic_assumptions.yoy_growth.obr.rpi From 1386278a79052c5ccdff7f78662c69e52adef36d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 14:44:30 -0400 Subject: [PATCH 15/29] Say the savings, property and carers taper changes are not yet law Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/carers_allowance/earnings_taper/index.yaml | 2 +- .../parameters/gov/hmrc/income_tax/rates/property.yaml | 2 +- .../parameters/gov/hmrc/income_tax/rates/savings.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml index 1859a223d7..20b788089b 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml @@ -1,4 +1,4 @@ -description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. +description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. It is announced policy, not yet legislated, and DWP will confirm the start date. metadata: label: Carer's Allowance earnings taper reference: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml index 3aab150eb3..9f08ffb10f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml @@ -1,4 +1,4 @@ -description: Property income tax rates. From April 2027, property income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. +description: Property income tax rates. From April 2027, property income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. The Budget 2026 rise is announced policy, not yet legislated. metadata: label: Property Income Tax rates unit: /1 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml index 96322ee4df..3bff5ead26 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml @@ -1,4 +1,4 @@ -description: Savings income tax rates. From April 2027, savings income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. +description: Savings income tax rates. From April 2027, savings income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. The Budget 2026 rise is announced policy, not yet legislated. metadata: label: Savings Income Tax rates unit: /1 From 835a765de32f66320b8c24657bfca0e043dc4775 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 15:52:07 -0400 Subject: [PATCH 16/29] Apply the Carer's Allowance couple and overlap rules to each part of 2030-31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 of #2719 found four problems in the round 1 fixes. - The couple restriction picked one partner for the whole of 2030-31, so a partner below the earnings limit lost the award for the weeks before the other partner's taper starts. carers_allowance_taper.py now gives the weekly award for the weeks before the taper and the weeks under it; the restriction and the overlapping-benefit rule apply to each part, weighted by its share of the year. carers_allowance_share_of_year_payable is gone. - A supplied carers_allowance_pre_overlap lost its overlap deduction when the earnings condition gave a zero share. It now meets the whole year's overlapping benefits, as before the taper. - The savings and property rate properties failed where the 24% finance cost relief changes the property allowance election. They now hold the election fixed, pin the counterexample, and a worked test covers the flip. - The excise duty docs still described flat gas rates and the old July uprating. CI at 1386278a7 also failed 8 YAML cases that pinned 2026 HBAI income for band D households in England without the £100 Household Energy Payment, which round 1 moved into HBAI income. They now include it. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 7 + docs/engineering/excise-duties.md | 39 ++-- .../income/uc_statutory_pay.yaml | 5 +- .../gov/ofgem/warm_home_discount.yaml | 10 +- .../income/hbai_property_income.yaml | 22 +-- .../income/trading_loss_household_income.yaml | 6 +- .../carers_allowance_earnings_taper.yaml | 34 ++++ ..._2026_savings_property_rates_properties.py | 71 +++++++- .../test_carers_allowance_earnings_taper.py | 166 +++++++++++++++++- .../utils/carers_allowance_taper.py | 50 ++++++ .../variables/gov/dwp/carers_allowance.py | 34 +++- .../dwp/carers_allowance_pre_couple_limit.py | 40 +---- .../gov/dwp/carers_allowance_pre_overlap.py | 17 +- .../carers_allowance_share_of_year_payable.py | 33 ---- 15 files changed, 417 insertions(+), 119 deletions(-) create mode 100644 policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml create mode 100644 policyengine_uk/utils/carers_allowance_taper.py delete mode 100644 policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index d97300303d..730113a2a0 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days, and overlapping benefits such as State Pension reduce the allowance only in those weeks. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index 432d00ca75..ea3b5a9bf7 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -95,6 +95,13 @@ order breaks an age tie. This is a modelling convention, not statutory priority. An ineligible reported recipient or head cannot displace an eligible partner. The caring-hours, earnings, age and take-up conditions are unchanged. +In 2030-31, the year the Budget 2026 Carer's Allowance earnings taper starts, +the model applies this convention separately to the weeks before the taper and +the weeks under it. A partner below the earnings limit then keeps the award +for the weeks before the other becomes eligible. A supplied +`carers_allowance_pre_overlap` carries no dates, so it is reduced by the whole +year's overlapping benefits. + The convention does not maximise the couple's payment. It can select a partner whose overlapping benefits reduce CA or CSP to zero, while suppressing a payment that the other otherwise-eligible partner could receive if selected. diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index b65fe28607..c32785f76c 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -21,8 +21,8 @@ default zero before 2024; that is an unmodelled period, not a statutory exemptio Gas duty covers 2022/23 onwards. SI 2026/555 removed the September 2026 step announced at Budget 2025 and moved the December 2026 step to 1 January 2027. Budget 2026 then kept the proportionate cut to 1 July 2027, when the gas rates -return to their full levels. Rates after the last dated entry stay constant; -the gas parameters do not forecast future inflation increases. +return to their full levels uprated by RPI, and rise by RPI each 1 April from +2028, as petrol and diesel do (see below). For alcohol and tobacco, a model year such as `2026` represents 6 April 2026 to 5 April 2027. Quantities and cigarette retail prices are constant across that @@ -154,15 +154,15 @@ matching the legislation. No universal litre-to-kilogram density is assumed. The existing `fuel_duty` output includes these charges. Rural fuel duty relief continues to apply only to petrol and diesel. -From 1 July 2027 onwards, the gas parameters hold LPG at £0.3161/kg and natural -gas at £0.2470/kg, the full rates before the 2022 cut. This implementation does -not extrapolate gas rates using an inflation forecast. This is a modelling -limitation: Budget 2026's costing says fuel duty rates are uprated by RPI from -1 July 2027. The petrol and diesel rate includes that forecast uprating, so its -rates rise while the gas rates stay flat. Later-year gas duty is therefore lower -than it would be under the same uprating assumption. Analyses beyond June 2027 -that require consistent fuel-duty forecasts must supply an uprated gas-rate path -explicitly. +Budget 2026's costing says that on 1 July 2027 "fuel duty rates will increase +by 5ppl and will be uprated in line with the Retail Prices Index (RPI)", and +applies the cut to other fuels in proportion. So on 1 July 2027 the gas rates +return to their full rates before the 2022 cut (LPG £0.3161/kg, natural gas +£0.2470/kg) uprated by the same RPI growth as petrol and diesel: £0.3305/kg +and £0.2583/kg. Each 1 April from 2028 they rise by the previous calendar +year's RPI growth. `calculate_fuel_duty_rates.py` writes these steps to +1 April 2031, as it does for petrol and diesel, and the parameters' uprating +metadata continues them. That is policy, not law. Sources: [HMRC fuel duty rates 2026 to 2027](https://www.gov.uk/government/publications/fuel-duty-rates-for-2026-to-2027/fuel-duty-rates-2026-to-2027) (RPI uprating from 2027/28), @@ -180,13 +180,16 @@ The main rate on unleaded petrol and diesel is keyed to each statutory change: cut to 1 July 2027. Its costing (page 13) says: "On 1 July 2027, fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be uprated annually in line with -RPI." That is policy, not law. No official source names the RPI measure. The -model takes the 57.95p pre-cut rate and applies 2026 RPI growth on 1 July 2027, -then the previous calendar year's RPI growth on each 1 April from 2028, from the -OBR forecast in `gov.economic_assumptions.yoy_growth.obr.rpi`, rounded to the -nearest hundredth of a penny. The steps to 1 April 2031 are written out by -`calculate_fuel_duty_rates.py`, and a test fails when they fall out of date -with the RPI forecast. Later steps come from the parameter's uprating metadata. +RPI." That is policy, not law. The OBR's October 2026 EFO (paragraph 3.19) +puts the rate at 60.59p from 1 July 2027: the 57.95p pre-cut rate uprated by +RPI growth in the year to 2027 Q1 (4.5565%, economy Table 1.7). The model +uses that figure, and `calculate_fuel_duty_rates.py` refuses to write any +other July rate. From 1 April 2028, each step applies the previous calendar +year's RPI growth from the OBR forecast in +`gov.economic_assumptions.yoy_growth.obr.rpi`, rounded to the nearest +hundredth of a penny. The script writes the steps to 1 April 2031, and a test +fails when they fall out of date with the RPI forecast. After a new EFO, set +the script's July rate and RPI growth to its forecast and rerun it. Later steps come from the parameter's uprating metadata. The RPI index ends in 2039, so the last step is 1 April 2040. An annual reform names a model year and replaces the rate for all of it. For diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml index 7da71d3a79..5e2e0bf2ec 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml @@ -252,9 +252,10 @@ household_benefits: 5_378.80 household_net_income: 5_378.80 # HBAI also counts the £150 Warm Home Discount the Universal Credit - # award brings in England, as miscellaneous income. + # award brings in England, as miscellaneous income; the Household Energy + # Payment is already in household net income. warm_home_discount: 150 - hbai_household_net_income: 5_428.80 + hbai_household_net_income: 5_528.80 gov_spending: 5_378.80 - name: Taxable statutory paternity pay reduces Universal Credit like wages diff --git a/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml b/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml index 39f563113d..56e8d78b02 100644 --- a/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml @@ -291,8 +291,9 @@ council_tax: 0 output: warm_home_discount: 150 - hbai_household_net_income: 1150 - hbai_household_net_income_ahc: 1150 + # Also the £100 Household Energy Payment: band D (the default) in England. + hbai_household_net_income: 1250 + hbai_household_net_income_ahc: 1250 - name: No rebate in HBAI income for a Universal Credit recipient in Scotland period: 2026 @@ -341,8 +342,9 @@ council_tax: 0 output: warm_home_discount: 0 - hbai_household_net_income: 1000 - hbai_household_net_income_ahc: 1000 + # Only the £100 Household Energy Payment: band D (the default) in England. + hbai_household_net_income: 1100 + hbai_household_net_income_ahc: 1100 - name: Universal Credit recipient in England, 2030-31, the last scheme year period: 2030 diff --git a/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml index d097928079..a977122b06 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml @@ -7,7 +7,9 @@ # income tax (ITTOIA 2005 s. 272A(4)) and are relieved by a tax reduction at # the basic rate instead (s. 274A). 2026-27: personal allowance £12,570, # basic rate 20% on the first £37,700 of taxable income, employee NI 8% -# from £12,570 to £50,270. +# from £12,570 to £50,270. Each household also gets the £100 Household Energy +# Payment (band D, the default, in England), which HBAI counts as +# miscellaneous income. - name: A landlord's mortgage interest is deducted from market and HBAI income period: 2026 @@ -26,8 +28,8 @@ income_tax: 6_686 # (40,000 - 12,570) x 8%. national_insurance: 2_194.40 - # 46,000 - 6,686 - 2,194.40. - hbai_household_net_income: 37_119.60 + # 46,000 - 6,686 - 2,194.40 + 100. + hbai_household_net_income: 37_219.60 - name: Finance costs above the profit leave the landlord with the income of a non-landlord period: 2026 @@ -59,8 +61,8 @@ # Landlord: (43,000 - 12,570) x 20% = 6,086, less 20% x min(8,000, # 3,000) = 600. Employee: (40,000 - 12,570) x 20%. income_tax: [5_486, 5_486] - # 40,000 - 5,486 - 2,194.40 for both. - hbai_household_net_income: [32_319.60, 32_319.60] + # 40,000 - 5,486 - 2,194.40 + 100 for both. + hbai_household_net_income: [32_419.60, 32_419.60] - name: Interest on the home is a housing cost and interest on a let property is not period: 2026 @@ -77,10 +79,10 @@ members: [person1] mortgage_interest_repayment: 3_000 output: - # As the first case: 46,000 - 6,686 - 2,194.40. - hbai_household_net_income: 37_119.60 + # As the first case: 46,000 - 6,686 - 2,194.40 + 100. + hbai_household_net_income: 37_219.60 # Less the 3,000 of interest on the household's own mortgage only. - hbai_household_net_income_ahc: 34_119.60 + hbai_household_net_income_ahc: 34_219.60 - name: Joint landlords each count their share of the profit after interest period: 2026 @@ -111,5 +113,5 @@ income_tax: [4_086, 4_086] # Each: (30,000 - 12,570) x 8%. national_insurance: [1_394.40, 1_394.40] - # 66,000 - 2 x 4,086 - 2 x 1,394.40. - hbai_household_net_income: 55_039.20 + # 66,000 - 2 x 4,086 - 2 x 1,394.40 + 100. + hbai_household_net_income: 55_139.20 diff --git a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml index e44490b022..967c594367 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml @@ -37,7 +37,7 @@ members: [without_loss] output: income_tax: [3_486, 5_486] - # -10,000 of loss + 2,000 of Income Tax saved. - hbai_household_net_income: [24_320, 32_320] - # Plus the £100 Household Energy Payment, which HBAI income leaves out. + # -10,000 of loss + 2,000 of Income Tax saved. Both include the £100 + # Household Energy Payment (band D, the default, in England). + hbai_household_net_income: [24_420, 32_420] household_net_income: [24_240, 32_240] diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml new file mode 100644 index 0000000000..e80b0bab07 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml @@ -0,0 +1,34 @@ +# Budget 2026 Carer's Allowance earnings taper (paragraph 6.13; policy +# costings page 16), from 6 October 2030 until DWP confirms the date. The +# amounts the taper pays follow the projected weekly rate, so +# test_carers_allowance_earnings_taper.py checks them against the parameter +# tree. These cases check what holds whatever the rate. +# +# A supplied carers_allowance_pre_overlap carries no dates, so the whole +# year's overlapping benefits come off it, as before the taper, even where the +# earnings condition says the allowance is paid for part of the year or none +# of it (Overlapping Benefits Regulations 1979 regs 4 and 12). + +- name: 2030-31, Northern Ireland, a supplied award meets the whole year's State Pension. + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + carers_allowance_pre_overlap: 4_495.40 + carers_allowance_earnings: 13_000 + state_pension: 6_000 + country: NORTHERN_IRELAND + output: + carers_allowance: 0 + +- name: 2030-31, England, a supplied award over the limit meets the whole year's State Pension. + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + carers_allowance_pre_overlap: 4_495.40 + carers_allowance_earnings: 13_000 + state_pension: 2_000 + country: ENGLAND + output: + carers_allowance: 2_495.40 diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index e154385690..09c62063dd 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -3,7 +3,8 @@ Budget 2026 adds 2 percentage points to the savings and property rates from 6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% and 47%. Each test compares current law with the Budget 2025 rates for the -same generated people, and checks identities that hold for every input: +same generated people, and checks identities that hold for every input +whose property allowance election is the same under both sets of rates: - rUK property tax rises by exactly 2% of taxable property income, since every pound of it sits in one of the three rate bands; @@ -16,11 +17,17 @@ relief was its full 22%. Where the tax left after earlier reductions capped it, the relief can rise by more: the higher rates leave more tax for it (test_capped_finance_cost_relief_can_rise_by_more_than_two_points). + +The election itself can change. The higher relief can make it worth giving up +the property allowance to deduct expenses and claim the relief, and then +property tax and the relief both move +(test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile). +The model makes that choice only where it lowers the person's income tax. """ 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 @@ -49,6 +56,10 @@ st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings ) cases = st.lists(case, min_size=CASES, max_size=CASES) +# Review round 2's counterexample: the fourth person (Scotland) gives up the +# property allowance at the higher rates. +ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES +ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) def situation(): @@ -79,6 +90,7 @@ def sims(): VARIABLES = [ "pays_scottish_income_tax", + "uses_property_allowance", "taxable_property_income_after_allowances", "property_income_tax", "savings_income_tax", @@ -104,6 +116,11 @@ def run(sims, drawn): return results +def same_election(law, before): + """People whose property allowance election the rates leave unchanged.""" + return law["uses_property_allowance"] == before["uses_property_allowance"] + + def tolerance(*values): """A penny plus float32 rounding on the amounts involved.""" return 0.01 + 1e-6 * sum(np.abs(v) for v in values) @@ -118,9 +135,10 @@ def tolerance(*values): @SETTINGS @given(cases) +@example(ELECTION_CHANGE) def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): law, before = run(sims, drawn) - ruk = ~law["pays_scottish_income_tax"].astype(bool) + ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) rise = law["property_income_tax"] - before["property_income_tax"] expected = RISE * law["taxable_property_income_after_allowances"] tol = tolerance(law["property_income_tax"], expected) @@ -129,9 +147,10 @@ def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): @SETTINGS @given(cases) +@example(ELECTION_CHANGE) def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): law, before = run(sims, drawn) - scottish = law["pays_scottish_income_tax"].astype(bool) + scottish = law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) rise = law["property_income_tax"] - before["property_income_tax"] tol = tolerance(law["property_income_tax"]) assert (np.abs(rise)[scottish] <= tol[scottish]).all() @@ -139,6 +158,7 @@ def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): @SETTINGS @given(cases) +@example(ELECTION_CHANGE) def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): law, before = run(sims, drawn) banded = ( @@ -148,11 +168,13 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): ) rise = law["savings_income_tax"] - before["savings_income_tax"] tol = tolerance(law["savings_income_tax"], banded) - assert (np.abs(rise - RISE * banded) <= tol).all() + same = same_election(law, before) + assert (np.abs(rise - RISE * banded) <= tol)[same].all() @SETTINGS @given(cases) +@example(ELECTION_CHANGE) def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): law, before = run(sims, drawn) rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] @@ -161,7 +183,7 @@ def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): assert (rise >= -tol).all() full_before = 0.22 * before["property_finance_costs_relieved"] uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol - assert (rise <= cap + tol)[uncapped].all() + assert (rise <= cap + tol)[uncapped & same_election(law, before)].all() def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): @@ -191,3 +213,40 @@ def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): assert float(law.calculate(relief, YEAR)[0]) == pytest.approx(240, abs=0.01) assert float(before.calculate("income_tax", YEAR)[0]) == pytest.approx(0, abs=0.01) assert float(law.calculate("income_tax", YEAR)[0]) == pytest.approx(246, abs=0.01) + + +def test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile(): + """A Scottish landlord with £500 of rent and £435 of finance costs. At + 22% the relief (£95.70) is worth less than the Scottish basic-rate tax + that the £1,000 property allowance saves on the rent (£100), so they use + the allowance. At 24% the relief is £104.40, so giving up the allowance + and claiming the relief lowers their income tax by £4.40, although their + property income tax rises from nothing to £100.""" + situation = { + "people": { + "person": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 20_000}, + "property_income": {YEAR: 500}, + "property_finance_costs": {YEAR: 435}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], "region": {YEAR: "SCOTLAND"}} + }, + } + law = Simulation(situation=situation) + before = Simulation(situation=situation, reform=BUDGET_2025_RATES) + + def value(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + assert value(before, "uses_property_allowance") == 1 + assert value(law, "uses_property_allowance") == 0 + assert value(before, "property_finance_cost_relief") == 0 + assert value(law, "property_finance_cost_relief") == pytest.approx(104.40, abs=0.01) + assert value(before, "property_income_tax") == 0 + assert value(law, "property_income_tax") == pytest.approx(100, abs=0.01) + income_tax_change = value(law, "income_tax") - value(before, "income_tax") + assert income_tax_change == pytest.approx(-4.40, abs=0.01) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index b71dbd2fd3..ab803e0c49 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -12,7 +12,10 @@ earnings above the limit, floored at zero; - the taper never pays less than the cliff edge it replaces; - weekly earnings plus the allowance never fall as earnings rise: no cliff; -- Scotland (Carer Support Payment) and Northern Ireland are unchanged. +- Scotland (Carer Support Payment) and Northern Ireland are unchanged; +- in 2030-31 the allowance paid is the weekly overlap rule over the weeks it + is paid, for single carers and for couples caring for the same person, where + the couple restriction picks a partner separately before and under the taper. """ import numpy as np @@ -280,3 +283,164 @@ def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() + + +# A couple caring for the same person gets one award at a time. In 2030-31 +# the couple restriction picks a partner separately for the weeks before the +# taper and the weeks under it. + + +def couple(head_weekly_earnings, partner_weekly_earnings, weekly_state_pension): + def member(age, weekly_earnings): + return { + "age": {START_YEAR: age}, + "care_hours": {START_YEAR: 40}, + "carers_allowance_earnings": {START_YEAR: 52 * weekly_earnings}, + "state_pension": {START_YEAR: 52 * weekly_state_pension}, + } + + return { + "people": { + "head": member(71, head_weekly_earnings), + "partner": member(70, partner_weekly_earnings), + }, + "benunits": { + "benunit": { + "members": ["head", "partner"], + "would_claim_uc": {START_YEAR: False}, + } + }, + "households": { + "household": { + "members": ["head", "partner"], + "country": {START_YEAR: "ENGLAND"}, + } + }, + } + + +def test_2030_31_couple_keeps_the_partner_award_before_the_taper(): + # The head is £46 over the limit and gets the tapered allowance from + # 6 October; the partner is under it and gets the full rate until then. + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit + 46, limit - 4, 40)) + assert sim.calculate("partners_care_for_same_severely_disabled_person", START_YEAR)[ + 0 + ] + head, partner = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert head == pytest.approx( + START_YEAR_SHARE * max(rate - 46 - 40, 0) * 52, abs=0.05 + ) + assert partner == pytest.approx( + (1 - START_YEAR_SHARE) * max(rate - 40, 0) * 52, abs=0.05 + ) + + +def test_a_couple_under_the_limit_gets_one_award_all_year(): + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit - 4, limit - 4, 40)) + head, partner = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert head == pytest.approx(max(rate - 40, 0) * 52, abs=0.05) + assert partner == 0 + + +# A supplied pre-overlap award carries no dates, so it meets the whole year's +# overlapping benefits, whatever the earnings condition says. + + +@pytest.mark.parametrize( + "region, supplied, pension, expected", + [ + ("NORTHERN_IRELAND", 4_495.40, 6_000, 0), + ("NORTH_WEST", 4_495.40, 2_000, 2_495.40), + ], +) +def test_a_supplied_pre_overlap_award_meets_the_whole_years_overlapping_benefits( + region, supplied, pension, expected +): + situation = carer(250, START_YEAR, region) + person = situation["people"]["carer"] + person["age"] = {START_YEAR: 70} + person["carers_allowance_pre_overlap"] = {START_YEAR: supplied} + person["state_pension"] = {START_YEAR: pension} + paid = Simulation(situation=situation).calculate("carers_allowance", START_YEAR) + assert float(paid[0]) == pytest.approx(expected, abs=0.05) + + +couple_cases = st.lists( + st.tuples( + weekly, + weekly, + st.floats(min_value=0, max_value=150), + st.floats(min_value=0, max_value=150), + ), + min_size=CASES, + max_size=CASES, +) + + +def couples_situation(): + people, benunits, households = {}, {}, {} + for i in range(CASES): + members = [f"head_{i}", f"partner_{i}"] + people[members[0]] = {"age": {START_YEAR: 71}, "care_hours": {START_YEAR: 40}} + people[members[1]] = {"age": {START_YEAR: 70}, "care_hours": {START_YEAR: 40}} + benunits[f"benunit_{i}"] = { + "members": members, + "would_claim_uc": {START_YEAR: False}, + } + households[f"household_{i}"] = { + "members": members, + "region": {START_YEAR: REGIONS[i % len(REGIONS)]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture(scope="module") +def couples_sim(): + return Simulation(situation=couples_situation()) + + +@SETTINGS +@given(couple_cases) +def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( + couples_sim, drawn +): + head_earnings, partner_earnings, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + sim = couples_sim + sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + sim.set_input("state_pension", START_YEAR, 52 * pension) + sim.reset_calculations() + assert sim.calculate("is_benunit_head", START_YEAR)[::2].all() + assert sim.calculate( + "partners_care_for_same_severely_disabled_person", START_YEAR + ).all() + paid = sim.calculate("carers_allowance", START_YEAR).astype(float).reshape(-1, 2) + above = ( + sim.calculate("carers_allowance_weekly_earnings_above_limit", START_YEAR) + .astype(float) + .reshape(-1, 2) + ) + _, rate = ca_parameters(START_YEAR) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(CASES)]) + tapers = np.isin(regions, TAPER_REGIONS)[:, None] + pension = pension.reshape(-1, 2) + expected = np.zeros_like(paid) + for share, under_taper in ((1 - START_YEAR_SHARE, False), (START_YEAR_SHARE, True)): + if under_taper: + weekly = np.where( + above > 0, np.where(tapers, np.maximum(0, rate - above), 0), rate + ) + else: + weekly = np.where(above > 0, 0, rate) + # The head when eligible, otherwise the partner. + head_paid = weekly[:, 0] > 0 + selected = np.column_stack([head_paid, ~head_paid]) + expected += share * 52 * selected * np.maximum(0, weekly - pension) + # Scotland pays Carer Support Payment instead. + england_wales_ni = regions != "SCOTLAND" + assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py new file mode 100644 index 0000000000..523c008ec9 --- /dev/null +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -0,0 +1,50 @@ +"""Carer's Allowance before and under the Budget 2026 earnings taper. + +Budget 2026 replaces the cliff edge at the earnings limit with a taper in +England and Wales from a date within 2030-31. Before the taper starts, a carer +above the limit gets nothing; once it applies, they get the rate less the +earnings above the limit. In the year it starts, each rule covers part of the +year. The couple restriction and the overlapping-benefit rule work week by +week, so each part gets them on its own award, and the parts are then weighted +by their share of the year. +""" + +import numpy as np + + +def carers_allowance_weekly_by_part_of_year(person, period, parameters): + """The weekly award, before the couple and overlap restrictions, in each + part of the year, as (share of the year, weekly award) pairs. A part with + no share of the year is left out, so outside the year the taper starts in + there is one part.""" + ca = parameters(period).gov.dwp.carers_allowance + in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" + csp_replaces_ca = period.start.year >= 2025 + receives_ca = person("carers_allowance_reported", period) > 0 + meets_age_condition = person("age", period) >= ca.min_age + meets_work_condition = person("care_hours", period) >= ca.min_hours + would_claim = person("would_claim_carers_allowance", period) + eligible = ( + ~(in_scotland & csp_replaces_ca) + & (meets_work_condition | receives_ca) + & would_claim + & meets_age_condition + ) + # SSCBA 1992 s. 70(1)(b): no allowance for anyone gainfully employed, + # which reg. 8 of the 1976 Regulations defines by an earnings limit. + # It applies to reported recipients too. + gainfully_employed = person("is_gainfully_employed_for_carers_allowance", period) + # Budget 2026 (para 6.13) replaces that cliff edge in England and Wales + # with a taper: the weekly allowance less taper.rate for each pound of + # weekly earnings above the limit. + taper = ca.earnings_taper + country = person.household("country", period).decode_to_str() + excess = person("carers_allowance_weekly_earnings_above_limit", period) + tapered = np.isin(country, list(taper.countries)) * np.maximum( + 0, ca.rate - taper.rate * excess + ) + parts = ( + (1 - taper.applies, np.where(gainfully_employed, 0, ca.rate)), + (taper.applies, np.where(gainfully_employed, tapered, ca.rate)), + ) + return [(share, eligible * weekly) for share, weekly in parts if share > 0] diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 4a0afbc65a..41c7f4b508 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_weekly_by_part_of_year, +) +from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner +from policyengine_uk.utils.supplied_inputs import supplied_input class carers_allowance(Variable): @@ -14,7 +19,12 @@ class carers_allowance(Variable): "Where the overlapping benefit is above the carer rate in some weeks " "and lower or absent in others, for example when it is paid for part " "of the year, the annual comparison pays less than the weekly rule " - "would; annual inputs carry no dates to do better." + "would; annual inputs carry no dates to do better. In the year the " + "Budget 2026 earnings taper starts, the weeks before the taper and " + "the weeks under it are compared separately, so overlapping benefits " + "reduce the allowance only in the weeks it is paid. A supplied " + "carers_allowance_pre_overlap carries no dates, so it is compared " + "with the whole year's overlapping benefits." ) definition_period = YEAR unit = GBP @@ -25,10 +35,20 @@ class carers_allowance(Variable): ) def formula(person, period, parameters): - pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) - # The overlap rule reduces the allowance only in weeks it is paid, so - # in a year the earnings taper covers in part, only that share of - # the overlapping benefits comes off. - share = person("carers_allowance_share_of_year_payable", period) - return max_(pre_overlap - share * overlapping, 0) + if supplied_input(person, "carers_allowance_pre_overlap", period) is not None: + pre_overlap = person("carers_allowance_pre_overlap", period) + return max_(pre_overlap - overlapping, 0) + # The overlap rule works week by week, so each part of a year the + # earnings taper starts in is reduced on its own award. + paid = 0 + for share, weekly in carers_allowance_weekly_by_part_of_year( + person, period, parameters + ): + award = limit_carer_benefit_to_one_partner( + person, period, weekly * WEEKS_IN_YEAR + ) + # Held at the precision of a stored amount, so that an + # overlapping benefit equal to the award leaves nothing. + paid = paid + share * max_(award.astype(np.float32) - overlapping, 0) + return paid diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py index da943a1548..eff25e73e0 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_weekly_by_part_of_year, +) class carers_allowance_pre_couple_limit(Variable): @@ -8,7 +11,10 @@ class carers_allowance_pre_couple_limit(Variable): documentation = ( "The existing age, caring, earnings and take-up calculation before " "the same-person couple restriction and overlapping-benefit adjustment. " - "This candidate amount is not final entitlement or payment." + "This candidate amount is not final entitlement or payment. In the " + "year the Budget 2026 earnings taper starts, it adds the award before " + "the taper starts and the award under it, each for its share of the " + "year." ) definition_period = YEAR unit = GBP @@ -18,33 +24,5 @@ class carers_allowance_pre_couple_limit(Variable): ) def formula(person, period, parameters): - in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" - csp_replaces_ca = period.start.year >= 2025 - receives_ca = person("carers_allowance_reported", period) > 0 - ca = parameters(period).gov.dwp.carers_allowance - meets_age_condition = person("age", period) >= ca.min_age - weekly_care_hours = person("care_hours", period) - meets_work_condition = weekly_care_hours >= ca.min_hours - would_claim = person("would_claim_carers_allowance", period) - # SSCBA 1992 s. 70(1)(b): no allowance for anyone gainfully employed, - # which reg. 8 of the 1976 Regulations defines by an earnings limit. - # It applies to reported recipients too. - gainfully_employed = person( - "is_gainfully_employed_for_carers_allowance", period - ) - # Budget 2026 (para 6.13) replaces that cliff edge in England and - # Wales with a taper: the weekly allowance less taper.rate for each - # pound of weekly earnings above the limit. A year the taper starts - # in part pays the tapered amount for the share of its weeks that - # carers_allowance_share_of_year_payable gives. - excess = person("carers_allowance_weekly_earnings_above_limit", period) - tapered = max_(0, ca.rate - ca.earnings_taper.rate * excess) - share = person("carers_allowance_share_of_year_payable", period) - weekly = share * where(gainfully_employed, tapered, ca.rate) - eligible = ( - ~(in_scotland & csp_replaces_ca) - & (meets_work_condition | receives_ca) - & would_claim - & meets_age_condition - ) - return eligible * weekly * WEEKS_IN_YEAR + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + return sum(share * weekly * WEEKS_IN_YEAR for share, weekly in parts) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index c251fc77d7..56ca8ac02d 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_weekly_by_part_of_year, +) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner @@ -15,7 +18,11 @@ class carers_allowance_pre_overlap(Variable): "personal benefit. A person whose allowance is reduced to nil keeps " "this underlying entitlement. is_entitled_to_carer_benefit combines " "the pre-overlap amounts and any benefit in payment for rules that " - "test entitlement. carers_allowance is the amount payable." + "test entitlement. carers_allowance is the amount payable. In the " + "year the Budget 2026 earnings taper starts, the couple restriction " + "picks a partner separately for the weeks before the taper and the " + "weeks under it, so a partner below the earnings limit keeps the " + "award for the weeks before the other becomes eligible." ) definition_period = YEAR unit = GBP @@ -25,5 +32,9 @@ class carers_allowance_pre_overlap(Variable): ) def formula(person, period, parameters): - candidate = person("carers_allowance_pre_couple_limit", period) - return limit_carer_benefit_to_one_partner(person, period, candidate) + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + return sum( + share + * limit_carer_benefit_to_one_partner(person, period, weekly * WEEKS_IN_YEAR) + for share, weekly in parts + ) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py b/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py deleted file mode 100644 index 6cc6f4e2b3..0000000000 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_share_of_year_payable.py +++ /dev/null @@ -1,33 +0,0 @@ -from policyengine_uk.model_api import * - - -class carers_allowance_share_of_year_payable(Variable): - value_type = float - entity = Person - label = "share of the year in which Carer's Allowance can be paid" - documentation = ( - "The share of the year's weeks in which the earnings condition lets " - "Carer's Allowance be paid. It is 1 for anyone at or below the " - "earnings limit. Above it, the allowance stops in full except where " - "the Budget 2026 earnings taper applies, so it is the share of the " - "year the taper covers in England and Wales (182/365 in 2030-31 on " - "the model's 6 October 2030 start, then 1) and 0 elsewhere. " - "carers_allowance deducts overlapping benefits only for this share " - "of the year, because the weekly overlap rule applies only in weeks " - "the allowance is paid." - ) - definition_period = YEAR - unit = "/1" - reference = ( - "https://www.legislation.gov.uk/ukpga/1992/4/section/70", - "https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=16", - ) - - def formula(person, period, parameters): - taper = parameters(period).gov.dwp.carers_allowance.earnings_taper - country = person.household("country", period).decode_to_str() - tapers = np.isin(country, list(taper.countries)) * taper.applies - gainfully_employed = person( - "is_gainfully_employed_for_carers_allowance", period - ) - return where(gainfully_employed, tapers, 1) From b1c4f95f9d5d87f9f3186cc8d8eba08315a41c80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 17:04:29 -0400 Subject: [PATCH 17/29] Carry supplied and reformed Carer's Allowance amounts through 2030-31 Review round 3 on #2719 found the 2030-31 calculation recomputed the award from its parts, so a value supplied for, or a reform that neutralizes, carers_allowance_pre_couple_limit or carers_allowance_pre_overlap no longer reached the payment. Each stage now reads the one before it. Within the parts of the year where a person's weekly award is positive it is the same amount, so carers_allowance_pre_overlap is the candidate award times the share of those parts the couple restriction keeps, and carers_allowance takes the overlapping benefits off for the share kept. A supplied carers_allowance_pre_couple_limit carries no dates, so the restriction and the whole year's overlap apply to it. Less than a penny for the year is nil: where an overlapping benefit equals the award, float32 left a fraction of a penny that made receives_carers_allowance true, in 2031-32 as well as 2030-31. Tests: supplied and neutralized intermediates in 2030-31 and 2031-32, a Hypothesis property for an overlapping benefit equal to the award, and a penny floor on the two 2030-31 properties. The 10 new tests fail on 835a765de. Also fixes the uc_statutory_pay comment (5,198.80 with the Household Energy Payment). Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 13 +- .../income/uc_statutory_pay.yaml | 6 +- .../test_carers_allowance_earnings_taper.py | 122 +++++++++++++++++- .../utils/carers_allowance_taper.py | 25 +++- policyengine_uk/utils/supplied_inputs.py | 1 + .../variables/gov/dwp/carers_allowance.py | 49 ++++--- .../gov/dwp/carers_allowance_pre_overlap.py | 22 +++- 8 files changed, 204 insertions(+), 36 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 730113a2a0..6f496d7390 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. Supplied or reformed amounts before the couple restriction or the overlap rule still reach the payment, and a payment of less than a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index ea3b5a9bf7..e75fb9a084 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -98,9 +98,16 @@ partner. The caring-hours, earnings, age and take-up conditions are unchanged. In 2030-31, the year the Budget 2026 Carer's Allowance earnings taper starts, the model applies this convention separately to the weeks before the taper and the weeks under it. A partner below the earnings limit then keeps the award -for the weeks before the other becomes eligible. A supplied -`carers_allowance_pre_overlap` carries no dates, so it is reduced by the whole -year's overlapping benefits. +for the weeks before the other becomes eligible. The parts of the year reach +the payment as shares of the year: `carers_allowance_pre_overlap` is the +amount from `carers_allowance_pre_couple_limit` that the restriction keeps, +and overlapping benefits come off for the share of the year it is kept, so a +value supplied for either variable, or a reform that replaces one, is what the +payment starts from. A supplied `carers_allowance_pre_couple_limit` or +`carers_allowance_pre_overlap` carries no dates, so the restriction and the +whole year's overlapping benefits apply to it. Less than a penny for the year, +which in practice is floating-point residue where an overlapping benefit +equals the award, is treated as nil. The convention does not maximise the couple's payment. It can select a partner whose overlapping benefits reduce CA or CSP to zero, while suppressing a diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml index 5e2e0bf2ec..02747d3f18 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml @@ -246,9 +246,9 @@ output: # 424.90 x 12 - 0.55 x 400, with no work allowance. universal_credit: 4_878.80 - # The award plus the pay. Without the pay the household has 5,098.80, so - # 400 of pay raises its income by 180. - # Plus the £100 Household Energy Payment: band D (the default) in England. + # The award plus the pay, plus the £100 Household Energy Payment: band D + # (the default) in England. Without the pay the household has + # 424.90 x 12 + 100 = 5,198.80, so 400 of pay raises its income by 180. household_benefits: 5_378.80 household_net_income: 5_378.80 # HBAI also counts the £150 Warm Home Discount the Universal Credit diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index ab803e0c49..5630835ec9 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -15,7 +15,11 @@ - Scotland (Carer Support Payment) and Northern Ireland are unchanged; - in 2030-31 the allowance paid is the weekly overlap rule over the weeks it is paid, for single carers and for couples caring for the same person, where - the couple restriction picks a partner separately before and under the taper. + the couple restriction picks a partner separately before and under the taper; +- an award supplied or neutralized before the couple limit or the overlap + rule is what reaches the payment; +- an overlapping benefit equal to the award leaves nothing paid or received, + and anything paid is at least a penny. """ import numpy as np @@ -283,6 +287,8 @@ def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() + # Anything paid is at least a penny. + assert ((paid == 0) | (paid >= 0.01)).all() # A couple caring for the same person gets one award at a time. In 2030-31 @@ -367,6 +373,119 @@ def test_a_supplied_pre_overlap_award_meets_the_whole_years_overlapping_benefits assert float(paid[0]) == pytest.approx(expected, abs=0.05) +# The award passes through carers_allowance_pre_couple_limit and +# carers_allowance_pre_overlap to the payment, so a value supplied for either, +# or a reform that replaces either, reaches the amount paid. + + +def one_carer_couple(year): + return { + "people": { + "head": {"age": {year: 71}, "care_hours": {year: 40}}, + "partner": { + "age": {year: 70}, + "care_hours": {year: 0}, + "would_claim_carers_allowance": {year: False}, + }, + }, + "benunits": { + "benunit": { + "members": ["head", "partner"], + "would_claim_uc": {year: False}, + } + }, + "households": { + "household": { + "members": ["head", "partner"], + "country": {year: "ENGLAND"}, + } + }, + } + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize("supplied", [[0, 0], [1_000, 0]]) +def test_a_supplied_award_before_the_couple_limit_is_the_award_paid(year, supplied): + sim = Simulation(situation=one_carer_couple(year)) + sim.set_input("carers_allowance_pre_couple_limit", year, supplied) + sim.reset_calculations() + for name in ("carers_allowance_pre_overlap", "carers_allowance"): + assert sim.calculate(name, year).astype(float) == pytest.approx(supplied) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "neutralized", ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"] +) +def test_a_neutralized_award_is_not_paid(year, neutralized): + from policyengine_core.reforms import Reform + + class Neutralize(Reform): + def apply(self): + self.neutralize_variable(neutralized) + + sim = Simulation(situation=one_carer_couple(year), reform=Neutralize) + assert (sim.calculate("carers_allowance", year) == 0).all() + assert not sim.calculate("receives_carers_allowance", year).any() + + +# An overlapping benefit equal to the weekly award extinguishes it. In +# float32 the annual amounts can leave a fraction of a penny, which is not a +# payment and is not received. + +residue_cases = st.lists( + st.integers(min_value=1, max_value=9_000), min_size=CASES, max_size=CASES +) + + +def residue_situation(year): + return { + "people": { + f"person_{i}": {"age": {year: 70}, "care_hours": {year: 40}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": { + "members": [f"person_{i}"], + "would_claim_uc": {year: False}, + } + for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": {year: "ENGLAND"}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def residue_sims(): + return { + year: Simulation(situation=residue_situation(year)) + for year in (START_YEAR, FULL_YEAR) + } + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@SETTINGS +@given(drawn=residue_cases) +def test_an_overlapping_benefit_equal_to_the_award_leaves_nothing_received( + residue_sims, year, drawn +): + _, rate = ca_parameters(year) + # Weekly earnings above the limit, in pence, short of the whole rate. + excess = np.minimum(np.array(drawn), int(rate * 100) - 1) / 100 + sim = residue_sims[year] + sim.set_input("carers_allowance_weekly_earnings_above_limit", year, excess) + sim.set_input("state_pension", year, 52 * (rate - excess)) + sim.reset_calculations() + assert (sim.calculate("carers_allowance", year) == 0).all() + assert not sim.calculate("receives_carers_allowance", year).any() + + couple_cases = st.lists( st.tuples( weekly, @@ -444,3 +563,4 @@ def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() + assert ((paid == 0) | (paid >= 0.01)).all() diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index 523c008ec9..d282523f36 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -5,12 +5,21 @@ above the limit gets nothing; once it applies, they get the rate less the earnings above the limit. In the year it starts, each rule covers part of the year. The couple restriction and the overlapping-benefit rule work week by -week, so each part gets them on its own award, and the parts are then weighted -by their share of the year. +week, so each part gets them on its own award, weighted by its share of the +year. + +A person's weekly award is the same in every part of the year in which it is +positive: the full rate wherever they are within the earnings limit, and the +tapered rate, which only the part under the taper pays, where they are above +it. So the annual amounts carry the parts as shares of the year: the +pre-overlap award is the candidate award scaled by the share the couple +restriction keeps, and the overlapping benefits come off for that share. """ import numpy as np +from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner + def carers_allowance_weekly_by_part_of_year(person, period, parameters): """The weekly award, before the couple and overlap restrictions, in each @@ -48,3 +57,15 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): (taper.applies, np.where(gainfully_employed, tapered, ca.rate)), ) return [(share, eligible * weekly) for share, weekly in parts if share > 0] + + +def carers_allowance_shares_of_year(person, period, parts): + """The share of the year in which the person has an award before the + couple restriction, and the share in which the restriction leaves it + with them, from carers_allowance_weekly_by_part_of_year's parts.""" + candidate = kept = 0 + for share, weekly in parts: + candidate = candidate + share * (weekly > 0) + selected = limit_carer_benefit_to_one_partner(person, period, weekly) + kept = kept + share * (selected > 0) + return candidate, kept diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 70e7b0edf3..785c54b7d3 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -41,6 +41,7 @@ { "adjusted_net_income", "carers_allowance", + "carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap", "carer_support_payment", "carer_support_payment_pre_overlap", diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 41c7f4b508..02fe62175f 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -1,8 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, ) -from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner from policyengine_uk.utils.supplied_inputs import supplied_input @@ -20,11 +20,11 @@ class carers_allowance(Variable): "and lower or absent in others, for example when it is paid for part " "of the year, the annual comparison pays less than the weekly rule " "would; annual inputs carry no dates to do better. In the year the " - "Budget 2026 earnings taper starts, the weeks before the taper and " - "the weeks under it are compared separately, so overlapping benefits " - "reduce the allowance only in the weeks it is paid. A supplied " - "carers_allowance_pre_overlap carries no dates, so it is compared " - "with the whole year's overlapping benefits." + "Budget 2026 earnings taper starts, overlapping benefits come off " + "only for the share of the year the allowance is paid in. A supplied " + "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " + "carries no dates, so the whole year's overlapping benefits come off " + "it. Less than a penny for the year is nil." ) definition_period = YEAR unit = GBP @@ -35,20 +35,27 @@ class carers_allowance(Variable): ) def formula(person, period, parameters): + pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) - if supplied_input(person, "carers_allowance_pre_overlap", period) is not None: - pre_overlap = person("carers_allowance_pre_overlap", period) - return max_(pre_overlap - overlapping, 0) - # The overlap rule works week by week, so each part of a year the - # earnings taper starts in is reduced on its own award. - paid = 0 - for share, weekly in carers_allowance_weekly_by_part_of_year( - person, period, parameters - ): - award = limit_carer_benefit_to_one_partner( - person, period, weekly * WEEKS_IN_YEAR + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + supplied = any( + supplied_input(person, name, period) is not None + for name in ( + "carers_allowance_pre_overlap", + "carers_allowance_pre_couple_limit", ) - # Held at the precision of a stored amount, so that an - # overlapping benefit equal to the award leaves nothing. - paid = paid + share * max_(award.astype(np.float32) - overlapping, 0) - return paid + ) + if len(parts) == 1 or supplied: + share_paid = 1 + else: + # The overlap rule works week by week, so in the year the + # earnings taper starts, the overlapping benefits come off only + # for the share of the year the person is paid in. + candidate_share, kept_share = carers_allowance_shares_of_year( + person, period, parts + ) + share_paid = where(candidate_share > 0, kept_share, 1) + paid = max_(pre_overlap - share_paid * overlapping, 0) + # Less than a penny for the year is in practice float32 residue where + # an overlapping benefit equals the award, not a payment. + return where(paid < 0.01, 0, paid) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 56ca8ac02d..e85a648c26 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,8 +1,10 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, ) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner +from policyengine_uk.utils.supplied_inputs import supplied_input class carers_allowance_pre_overlap(Variable): @@ -22,7 +24,9 @@ class carers_allowance_pre_overlap(Variable): "year the Budget 2026 earnings taper starts, the couple restriction " "picks a partner separately for the weeks before the taper and the " "weeks under it, so a partner below the earnings limit keeps the " - "award for the weeks before the other becomes eligible." + "award for the weeks before the other becomes eligible. A supplied " + "carers_allowance_pre_couple_limit carries no dates, so the " + "restriction applies to it for the whole year." ) definition_period = YEAR unit = GBP @@ -32,9 +36,17 @@ class carers_allowance_pre_overlap(Variable): ) def formula(person, period, parameters): + candidate = person("carers_allowance_pre_couple_limit", period) + whole_year = limit_carer_benefit_to_one_partner(person, period, candidate) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - return sum( - share - * limit_carer_benefit_to_one_partner(person, period, weekly * WEEKS_IN_YEAR) - for share, weekly in parts + supplied = supplied_input(person, "carers_allowance_pre_couple_limit", period) + if len(parts) == 1 or supplied is not None: + return whole_year + # In the year the earnings taper starts, a person keeps their award + # for the parts of the year the restriction picks them in. + candidate_share, kept_share = carers_allowance_shares_of_year( + person, period, parts ) + has_candidate = candidate_share > 0 + kept = kept_share / where(has_candidate, candidate_share, 1) + return where(has_candidate, candidate * kept, whole_year) From 7de3747f3f53a36f63e1729ba48568312c246c31 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 17:42:24 -0400 Subject: [PATCH 18/29] Uprate the Pension Tax Allowance threshold by the triple lock index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threshold was uprated by the new State Pension amount itself, so a reform that sets that amount to nil divided by zero in parameter uprating (main's test_state_pension_components reforms it to 0, and CI failed on the merge ref at 835a765de). The new State Pension is projected with the triple lock index, so uprating the threshold by the same index gives the same baseline values (to 1e-11 in 2028-29 to 2031-32). A reform to the new State Pension rate now leaves the threshold alone unless the threshold is reformed with it. Tests: the threshold rises in line with the new State Pension without a reform, and a rate reform of nil or £300 leaves it and gives a finite allowance (both fail on the old uprating). Co-Authored-By: Claude Opus 5.5 --- .../state_pension_threshold.yaml | 8 +++-- .../test_pension_tax_allowance_properties.py | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml index 6bdb91c45c..65bd7b9f83 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml @@ -11,8 +11,12 @@ metadata: label: Pension Tax Allowance State Pension threshold # "For tax years 2028-29 and 2029-30, the value of the Pension Tax # Allowance will be uprated in line with the full annual value of the new - # State Pension." Later years follow the weekly new State Pension rate. - uprating: gov.dwp.state_pension.new_state_pension.amount + # State Pension." The new State Pension is projected with the triple lock + # index, so this follows it in the baseline. Uprating by the pension's own + # amount would divide by zero under a reform that sets it to nil, so a + # reform to the new State Pension rate leaves this threshold alone unless + # the threshold is reformed with it. + uprating: gov.economic_assumptions.indices.triple_lock reference: - title: HM Treasury, Pension Tax Allowance explainer, section 1 and FAQ href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer diff --git a/policyengine_uk/tests/test_pension_tax_allowance_properties.py b/policyengine_uk/tests/test_pension_tax_allowance_properties.py index 2d3b7aa72a..afe139939e 100644 --- a/policyengine_uk/tests/test_pension_tax_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_tax_allowance_properties.py @@ -165,3 +165,35 @@ def test_the_allowance_falls_by_at_most_a_third_of_extra_income(sims, drawn, yea # no faster than the 1-in-3 taper: no cliff edge at the threshold or at # the point where the cap stops binding. assert (base - more <= STEP / 3 + 0.01).all() + + +def threshold(sim, year): + pta = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.reliefs + return pta.pension_tax_allowance.state_pension_threshold(f"{year}-04-06") + + +def new_state_pension(sim, year): + nsp = sim.tax_benefit_system.parameters.gov.dwp.state_pension.new_state_pension + return nsp.amount(f"{year}-04-06") + + +def test_the_threshold_rises_in_line_with_the_new_state_pension(): + sim = Simulation(situation=situation()) + for year in YEARS[1:]: + assert threshold(sim, year) / threshold(sim, YEARS[0]) == pytest.approx( + new_state_pension(sim, year) / new_state_pension(sim, YEARS[0]), + rel=1e-9, + ) + + +@pytest.mark.parametrize("weekly_rate", [0.0, 300.0]) +def test_a_new_state_pension_rate_reform_leaves_the_threshold(weekly_rate): + # The threshold follows the triple lock index, as the new State Pension + # does without a reform, so a rate of nil does not divide by zero. + rate = "gov.dwp.state_pension.new_state_pension.amount" + sim = Simulation(situation=situation()) + reformed = Simulation(situation=situation(), reform={rate: weekly_rate}) + for year in YEARS: + assert new_state_pension(reformed, year) == weekly_rate + assert threshold(reformed, year) == threshold(sim, year) + assert np.isfinite(reformed.calculate("pension_tax_allowance", year)).all() From d436a27e1e18ef188c192a483893120616317a19 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:04:12 -0400 Subject: [PATCH 19/29] Follow reformed intermediates and the full new State Pension rate Carer's Allowance in 2030-31 uses the shares of the year only where an amount is the award the parts of the year add up to, to the penny; an amount supplied or set by a reform gets the whole year's couple restriction and overlap, so a replacement reaches the payment. A payment under half a penny for the year is nil, so a penny left after overlap is paid. The Pension Tax Allowance threshold is 52 weeks at the new State Pension weekly rate, computed in the formula, so it follows dated and undated reforms to the rate; the threshold parameter is removed. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../ab26-pension-tax-allowance.added.md | 2 +- .../carer-support-payment-earnings.md | 20 +++-- .../state_pension_threshold.yaml | 22 ----- .../pension_tax_allowance.yaml | 55 +++++++----- .../test_carers_allowance_earnings_taper.py | 89 +++++++++++++++++-- .../test_pension_tax_allowance_properties.py | 86 +++++++++++------- .../utils/carers_allowance_taper.py | 33 +++++-- .../variables/gov/dwp/carers_allowance.py | 25 ++++-- .../dwp/carers_allowance_pre_couple_limit.py | 3 +- .../gov/dwp/carers_allowance_pre_overlap.py | 23 +++-- .../reliefs/pension_tax_allowance.py | 12 ++- 12 files changed, 256 insertions(+), 116 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 6f496d7390..9ead549dde 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. Supplied or reformed amounts before the couple restriction or the overlap rule still reach the payment, and a payment of less than a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. Amounts supplied, neutralized or replaced by a reform before the couple restriction or the overlap rule reach the payment, with the whole year's restriction and overlap, and a payment of less than half a penny for the year is nil. diff --git a/changelog.d/ab26-pension-tax-allowance.added.md b/changelog.d/ab26-pension-tax-allowance.added.md index 2663bdbd65..a9b9d06ae4 100644 --- a/changelog.d/ab26-pension-tax-allowance.added.md +++ b/changelog.d/ab26-pension-tax-allowance.added.md @@ -1 +1 @@ -Add the Pension Tax Allowance announced at Budget 2026: from 6 April 2027 to 5 April 2030, people over State Pension age (or reaching it within the tax year) get a non-refundable Income Tax reduction of 20% of the full new State Pension above the Personal Allowance, rounded up to the pound (£94 in 2027-28), tapered by £1 for every £3 of total income above the full new State Pension. Universal Credit's deduction for tax on earnings sees it. +Add the Pension Tax Allowance announced at Budget 2026: from 6 April 2027 to 5 April 2030, people over State Pension age (or reaching it within the tax year) get a non-refundable Income Tax reduction of 20% of the full new State Pension above the Personal Allowance, rounded up to the pound (£94 in 2027-28), tapered by £1 for every £3 of total income above the full new State Pension (52 weeks at the weekly rate, so it follows any change to that rate). Universal Credit's deduction for tax on earnings sees it. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index e75fb9a084..076f34571e 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -101,13 +101,19 @@ the weeks under it. A partner below the earnings limit then keeps the award for the weeks before the other becomes eligible. The parts of the year reach the payment as shares of the year: `carers_allowance_pre_overlap` is the amount from `carers_allowance_pre_couple_limit` that the restriction keeps, -and overlapping benefits come off for the share of the year it is kept, so a -value supplied for either variable, or a reform that replaces one, is what the -payment starts from. A supplied `carers_allowance_pre_couple_limit` or -`carers_allowance_pre_overlap` carries no dates, so the restriction and the -whole year's overlapping benefits apply to it. Less than a penny for the year, -which in practice is floating-point residue where an overlapping benefit -equals the award, is treated as nil. +and overlapping benefits come off for the share of the year it is kept. + +The shares describe an amount only where it is the one the parts of the year +add up to, to the penny. A `carers_allowance_pre_couple_limit` that is +supplied, or that a reform sets to anything else, carries no dates: the +couple restriction applies to the benefit unit's awards for the whole year, +and the whole year's overlapping benefits come off what it keeps. A +`carers_allowance_pre_overlap` that is supplied or set by a reform is past +the couple restriction, so only the whole year's overlapping benefits come +off it. Either way, the value supplied or set is what the payment starts +from. Less than half a penny for the year, which in practice is +floating-point residue where an overlapping benefit equals the award, is +treated as nil; a penny is paid. The convention does not maximise the couple's payment. It can select a partner whose overlapping benefits reduce CA or CSP to zero, while suppressing a diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml deleted file mode 100644 index 65bd7b9f83..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/state_pension_threshold.yaml +++ /dev/null @@ -1,22 +0,0 @@ -description: Total income up to which the full Pension Tax Allowance applies, the full annual value of the new State Pension for the tax year. The allowance tapers away above it. -values: - # 52 weeks at £250.70. The explainer: "From the tax year beginning - # 6 April 2027, the full annual value of the new State Pension will - # increase to £13,036.40", worked out as the full new State Pension - # receivable from 6 April 2027 to 5 April 2028. - 2027-04-06: 13_036.40 -metadata: - unit: currency-GBP - period: year - label: Pension Tax Allowance State Pension threshold - # "For tax years 2028-29 and 2029-30, the value of the Pension Tax - # Allowance will be uprated in line with the full annual value of the new - # State Pension." The new State Pension is projected with the triple lock - # index, so this follows it in the baseline. Uprating by the pension's own - # amount would divide by zero under a reform that sets it to nil, so a - # reform to the new State Pension rate leaves this threshold alone unless - # the threshold is reformed with it. - uprating: gov.economic_assumptions.indices.triple_lock - reference: - - title: HM Treasury, Pension Tax Allowance explainer, section 1 and FAQ - href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml index 2d6f5f4816..adba46cb97 100644 --- a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml @@ -3,12 +3,19 @@ # A non-refundable Income Tax reduction for people over State Pension age, or # reaching it within the tax year, from 6 April 2027 to 5 April 2030. The full # allowance is 20% of the full new State Pension above the Personal Allowance, -# rounded up to the pound: 20% x (13,036.40 - 12,570) = 93.28, so 94 in -# 2027-28. It tapers by 1 for every 3 of total income above 13,036.40, so it -# is gone from 13,318.40 (the explainer's "13,319 or more"). +# rounded up to the pound, and it tapers by 1 for every 3 of total income +# above the full new State Pension. +# +# The full new State Pension is 52 weeks at the weekly rate. The model +# projects the April 2027 rate unrounded, 241.30 x 1.039 = 250.71, so the +# threshold is 13,036.97, where HM Treasury's explainer gives 13,036.40 +# (250.70 a week). The full allowance is 94 on either: 20% x (13,036.97 - +# 12,570) = 93.39. It is gone from 13,036.97 + 3 x 94 = 13,318.97 (the +# explainer's "13,319 or more"). # # The first five cases are the worked examples in HM Treasury's Pension Tax -# Allowance explainer. +# Allowance explainer, on its inputs. Jonathan's allowance is 19p more than +# the explainer's 44 because the threshold is 57p higher. - name: Tom, below the Personal Allowance, has no tax to remove period: 2027 @@ -45,7 +52,7 @@ pension_tax_allowance: 80 income_tax: 0 -- name: Jonathan, deferring his pension with income in the taper, gets 44 +- name: Jonathan, deferring his pension with income in the taper, gets 44.19 period: 2027 absolute_error_margin: 0.01 input: @@ -54,12 +61,12 @@ property_income: 1_186.40 output: total_income: 13_186.40 - # 94 - (13,186.40 - 13,036.40) / 3 - pension_tax_allowance: 44 + # 94 - (13,186.40 - 13,036.97) / 3; the explainer, on 13,036.40, gives 44 + pension_tax_allowance: 44.19 # The Personal Allowance covers the salary and 570 of the property # profit; 616.40 x 24% (the Budget 2026 property basic rate) = 147.94 income_tax_pre_charges: 147.94 - income_tax: 103.94 + income_tax: 103.75 - name: Nadia, above 13,319, gets no allowance period: 2027 @@ -81,22 +88,24 @@ absolute_error_margin: 0.01 input: age: 70 - # 13,036.40 + 3 x 94 - state_pension: 13_318.40 + # Just above 13,036.97 + 3 x 94 + state_pension: 13_318.98 output: pension_tax_allowance: 0 - income_tax: 149.68 + # 748.98 x 20% + income_tax: 149.80 - name: A pound before the end of the taper leaves a third of a pound period: 2027 absolute_error_margin: 0.01 input: age: 70 - state_pension: 13_317.40 + state_pension: 13_317.98 output: + # 94 - (13,317.98 - 13,036.97) / 3 pension_tax_allowance: 0.33 - # 747.40 x 20% = 149.48, less the allowance - income_tax: 149.15 + # 747.98 x 20% = 149.60, less the allowance + income_tax: 149.27 - name: Scottish taxpayers get the allowance at 20%, capped at their starter-rate tax period: 2027 @@ -120,11 +129,11 @@ private_pension_income: 13_186.40 region: SCOTLAND output: - # 94 - 150 / 3; at the 19% starter rate the full allowance would have - # been 89, leaving 39 - pension_tax_allowance: 44 + # 94 - 149.43 / 3; at the 19% starter rate the full allowance would have + # been 89, leaving 39.19 + pension_tax_allowance: 44.19 # 616.40 x 19% = 117.12 - income_tax: 73.12 + income_tax: 72.93 - name: Someone under State Pension age with the same income gets nothing period: 2027 @@ -187,11 +196,11 @@ absolute_error_margin: 0.01 input: age: 70 - # The threshold is 13,036.40 uprated with the new State Pension: 13,362.33 - # on the current forecast, so 20% x (13,362.33 - 12,570) = 158.47 and the + # The threshold is 52 weeks of the April 2028 new State Pension: 13,362.92 + # on the current forecast, so 20% x (13,362.92 - 12,570) = 158.58 and the # full allowance is 159. 300 above the threshold takes 100 off it. - private_pension_income: 13_662.33 + private_pension_income: 13_662.92 output: pension_tax_allowance: 59 - # 1,092.33 x 20% = 218.47, less the allowance - income_tax: 159.47 + # 1,092.92 x 20% = 218.58, less the allowance + income_tax: 159.58 diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 5630835ec9..a5da1f507b 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -16,10 +16,10 @@ - in 2030-31 the allowance paid is the weekly overlap rule over the weeks it is paid, for single carers and for couples caring for the same person, where the couple restriction picks a partner separately before and under the taper; -- an award supplied or neutralized before the couple limit or the overlap - rule is what reaches the payment; +- an award supplied, neutralized or replaced by a reform before the couple + limit or the overlap rule is what reaches the payment; - an overlapping benefit equal to the award leaves nothing paid or received, - and anything paid is at least a penny. + a penny left over is paid, and anything paid is at least half a penny. """ import numpy as np @@ -287,8 +287,8 @@ def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() - # Anything paid is at least a penny. - assert ((paid == 0) | (paid >= 0.01)).all() + # Anything paid is at least half a penny. + assert ((paid == 0) | (paid >= 0.005)).all() # A couple caring for the same person gets one award at a time. In 2030-31 @@ -429,6 +429,83 @@ def apply(self): assert not sim.calculate("receives_carers_allowance", year).any() +def both_caring_couple(year, weekly_state_pension=0, head_weekly_excess=0): + def member(age, weekly_excess=0): + return { + "age": {year: age}, + "care_hours": {year: 40}, + "state_pension": {year: 52 * weekly_state_pension}, + "carers_allowance_weekly_earnings_above_limit": {year: weekly_excess}, + } + + situation = one_carer_couple(year) + situation["people"] = { + "head": member(71, head_weekly_excess), + "partner": member(70), + } + return situation + + +def replaced(name, values): + """A reform giving a variable's formula fixed values for the two people.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables[name]) + + class Replaced(variable): + def formula(person, period, parameters): + return np.array(values, dtype=float) + + Replaced.__name__ = name + + class Replace(Reform): + def apply(self): + self.update_variable(Replaced) + + return Replace + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_replacing_the_award_before_the_couple_limit_is_paid(year): + # Both partners are eligible, so the parts of the year would pick the + # head; the reform gives the award to the partner instead. + reform = replaced("carers_allowance_pre_couple_limit", [0, 1_000]) + sim = Simulation(situation=both_caring_couple(year), reform=reform) + for name in ("carers_allowance_pre_overlap", "carers_allowance"): + assert sim.calculate(name, year).astype(float) == pytest.approx([0, 1_000]) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_replacing_the_award_before_overlap_meets_the_whole_years_pension( + year, +): + # The head is £46 a week over the earnings limit, so in 2030-31 the parts + # of the year would pay them only under the taper. The replaced £1,000 + # carries no dates, so £400 a year of State Pension comes off it in full. + reform = replaced("carers_allowance_pre_overlap", [1_000, 0]) + situation = both_caring_couple(year, 400 / 52, head_weekly_excess=46) + sim = Simulation(situation=situation, reform=reform) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([600, 0], abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize("award", [1_000.01, 5_000.01]) +def test_a_penny_left_after_overlap_is_paid(year, award): + # Float32 stores 5,000.01 as 5,000.0098, so the penny left over is 0.98p. + situation = carer(0, year, "NORTH_WEST") + person = situation["people"]["carer"] + person["age"] = {year: 70} + person["carers_allowance_pre_overlap"] = {year: award} + person["state_pension"] = {year: round(award - 0.01, 2)} + sim = Simulation(situation=situation) + assert float(sim.calculate("carers_allowance", year)[0]) == pytest.approx( + 0.01, abs=0.001 + ) + assert sim.calculate("receives_carers_allowance", year)[0] + + # An overlapping benefit equal to the weekly award extinguishes it. In # float32 the annual amounts can leave a fraction of a penny, which is not a # payment and is not received. @@ -563,4 +640,4 @@ def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() - assert ((paid == 0) | (paid >= 0.01)).all() + assert ((paid == 0) | (paid >= 0.005)).all() diff --git a/policyengine_uk/tests/test_pension_tax_allowance_properties.py b/policyengine_uk/tests/test_pension_tax_allowance_properties.py index afe139939e..b1323fc15a 100644 --- a/policyengine_uk/tests/test_pension_tax_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_tax_allowance_properties.py @@ -14,7 +14,10 @@ - Income Tax falls by exactly the allowance relative to the same law without it, and never goes below zero; - the taper has no cliff edge: each extra pound of pension income takes at - most a third of a pound off the allowance. + most a third of a pound off the allowance; +- the threshold is 52 weeks of the new State Pension at its weekly rate, so + someone on the full new State Pension and nothing else pays no Income Tax, + whatever the rate. """ import numpy as np @@ -101,7 +104,7 @@ def run(sims, drawn, year): def full_allowance(sim, year): p = sim.tax_benefit_system.parameters.gov.hmrc.income_tax - threshold = p.reliefs.pension_tax_allowance.state_pension_threshold(f"{year}-04-30") + threshold = 52 * new_state_pension(sim, year) allowance = p.allowances.personal_allowance.amount(f"{year}-04-30") return threshold, np.ceil(round(0.2 * (threshold - allowance), 6)) @@ -167,33 +170,56 @@ def test_the_allowance_falls_by_at_most_a_third_of_extra_income(sims, drawn, yea assert (base - more <= STEP / 3 + 0.01).all() -def threshold(sim, year): - pta = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.reliefs - return pta.pension_tax_allowance.state_pension_threshold(f"{year}-04-06") - - def new_state_pension(sim, year): nsp = sim.tax_benefit_system.parameters.gov.dwp.state_pension.new_state_pension - return nsp.amount(f"{year}-04-06") - - -def test_the_threshold_rises_in_line_with_the_new_state_pension(): - sim = Simulation(situation=situation()) - for year in YEARS[1:]: - assert threshold(sim, year) / threshold(sim, YEARS[0]) == pytest.approx( - new_state_pension(sim, year) / new_state_pension(sim, YEARS[0]), - rel=1e-9, - ) - - -@pytest.mark.parametrize("weekly_rate", [0.0, 300.0]) -def test_a_new_state_pension_rate_reform_leaves_the_threshold(weekly_rate): - # The threshold follows the triple lock index, as the new State Pension - # does without a reform, so a rate of nil does not divide by zero. - rate = "gov.dwp.state_pension.new_state_pension.amount" - sim = Simulation(situation=situation()) - reformed = Simulation(situation=situation(), reform={rate: weekly_rate}) - for year in YEARS: - assert new_state_pension(reformed, year) == weekly_rate - assert threshold(reformed, year) == threshold(sim, year) - assert np.isfinite(reformed.calculate("pension_tax_allowance", year)).all() + return nsp.amount(f"{year}-04-30") + + +def pensioner(year, state_pension): + return { + "people": { + "pensioner": {"age": {year: 70}, "state_pension": {year: state_pension}} + }, + "benunits": {"benunit": {"members": ["pensioner"]}}, + "households": { + "household": {"members": ["pensioner"], "region": {year: "LONDON"}} + }, + } + + +RATE = "gov.dwp.state_pension.new_state_pension.amount" + + +def tax_on_the_full_new_state_pension(year, reform): + weekly = new_state_pension( + Simulation(situation=pensioner(year, 0), reform=reform), year + ) + sim = Simulation(situation=pensioner(year, 52 * weekly), reform=reform) + return ( + float(sim.calculate("income_tax", year)[0]), + float(sim.calculate("pension_tax_allowance", year)[0]), + ) + + +@SETTINGS +@given(st.floats(min_value=0, max_value=400), years) +def test_the_full_new_state_pension_pays_no_income_tax_at_any_rate(weekly, year): + # The threshold is 52 weeks at the weekly rate, so it follows a reform to + # the rate, and a rate of nil gives nothing rather than dividing by zero. + income_tax, pta = tax_on_the_full_new_state_pension(year, {RATE: weekly}) + assert income_tax == pytest.approx(0, abs=0.01) + assert np.isfinite(pta) and pta >= 0 + + +@pytest.mark.parametrize( + "year, reform", + [(2027, None), (2028, None), (2029, None), (2028, {RATE: {"2028": 300}})], +) +def test_the_full_new_state_pension_pays_no_income_tax(year, reform): + # Without a reform, and with the rate raised to £300 for 2028-29 only: + # the allowance there is 20% x (15,600 - 12,570) = £606. + income_tax, pta = tax_on_the_full_new_state_pension(year, reform) + assert income_tax == pytest.approx(0, abs=0.01) + assert pta > 0 + if reform: + assert pta == pytest.approx(606, abs=0.01) diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index d282523f36..d19b6adf74 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -14,12 +14,21 @@ it. So the annual amounts carry the parts as shares of the year: the pre-overlap award is the candidate award scaled by the share the couple restriction keeps, and the overlapping benefits come off for that share. + +The shares describe an annual award only where it is the one the parts add +up to. An award supplied as an input or set by a reform carries no dates, so +it gets the whole-year treatment. """ import numpy as np +from policyengine_core.commons.formulas import WEEKS_IN_YEAR from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner +# Annual amounts are float32, which at these sizes is good to about a +# thousandth of a pound, so a penny tells the parts' own award from another. +PENNY = 0.01 + def carers_allowance_weekly_by_part_of_year(person, period, parameters): """The weekly award, before the couple and overlap restrictions, in each @@ -59,13 +68,25 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): return [(share, eligible * weekly) for share, weekly in parts if share > 0] +def carers_allowance_annual_award(parts): + """The annual award the parts of the year add up to.""" + return sum(share * weekly * WEEKS_IN_YEAR for share, weekly in parts) + + def carers_allowance_shares_of_year(person, period, parts): - """The share of the year in which the person has an award before the - couple restriction, and the share in which the restriction leaves it - with them, from carers_allowance_weekly_by_part_of_year's parts.""" - candidate = kept = 0 + """From carers_allowance_weekly_by_part_of_year's parts: the annual award + the couple restriction keeps, the share of the year in which the person + has an award before the restriction, and the share in which the + restriction leaves it with them.""" + kept_award = candidate = kept = 0 for share, weekly in parts: - candidate = candidate + share * (weekly > 0) selected = limit_carer_benefit_to_one_partner(person, period, weekly) + kept_award = kept_award + share * selected * WEEKS_IN_YEAR + candidate = candidate + share * (weekly > 0) kept = kept + share * (selected > 0) - return candidate, kept + return kept_award, candidate, kept + + +def is_the_parts_award(amount, award): + """Whether an annual amount is the award the parts give, to the penny.""" + return np.abs(amount - award) < PENNY diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 02fe62175f..4bfb3e0555 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -2,6 +2,7 @@ from policyengine_uk.utils.carers_allowance_taper import ( carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, + is_the_parts_award, ) from policyengine_uk.utils.supplied_inputs import supplied_input @@ -21,10 +22,12 @@ class carers_allowance(Variable): "of the year, the annual comparison pays less than the weekly rule " "would; annual inputs carry no dates to do better. In the year the " "Budget 2026 earnings taper starts, overlapping benefits come off " - "only for the share of the year the allowance is paid in. A supplied " + "only for the share of the year the allowance is paid in. A " "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " - "carries no dates, so the whole year's overlapping benefits come off " - "it. Less than a penny for the year is nil." + "that is supplied, or that a reform makes other than the award the " + "parts of the year give, carries no dates, so the whole year's " + "overlapping benefits come off it. Less than half a penny for the " + "year is nil." ) definition_period = YEAR unit = GBP @@ -50,12 +53,16 @@ def formula(person, period, parameters): else: # The overlap rule works week by week, so in the year the # earnings taper starts, the overlapping benefits come off only - # for the share of the year the person is paid in. - candidate_share, kept_share = carers_allowance_shares_of_year( + # for the share of the year the person is paid in, where the + # pre-overlap award is the one the parts of the year give. + kept_award, candidate_share, kept_share = carers_allowance_shares_of_year( person, period, parts ) - share_paid = where(candidate_share > 0, kept_share, 1) + by_parts = is_the_parts_award(pre_overlap, kept_award) & ( + candidate_share > 0 + ) + share_paid = where(by_parts, kept_share, 1) paid = max_(pre_overlap - share_paid * overlapping, 0) - # Less than a penny for the year is in practice float32 residue where - # an overlapping benefit equals the award, not a payment. - return where(paid < 0.01, 0, paid) + # Where an overlapping benefit equals the award, float32 leaves about a + # thousandth of a pound, which is not a payment; a penny is. + return where(paid < 0.005, 0, paid) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py index eff25e73e0..0341e9ec77 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_annual_award, carers_allowance_weekly_by_part_of_year, ) @@ -25,4 +26,4 @@ class carers_allowance_pre_couple_limit(Variable): def formula(person, period, parameters): parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - return sum(share * weekly * WEEKS_IN_YEAR for share, weekly in parts) + return carers_allowance_annual_award(parts) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index e85a648c26..46971e5cf8 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,7 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_annual_award, carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, + is_the_parts_award, ) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner from policyengine_uk.utils.supplied_inputs import supplied_input @@ -24,9 +26,11 @@ class carers_allowance_pre_overlap(Variable): "year the Budget 2026 earnings taper starts, the couple restriction " "picks a partner separately for the weeks before the taper and the " "weeks under it, so a partner below the earnings limit keeps the " - "award for the weeks before the other becomes eligible. A supplied " - "carers_allowance_pre_couple_limit carries no dates, so the " - "restriction applies to it for the whole year." + "award for the weeks before the other becomes eligible. A " + "carers_allowance_pre_couple_limit that is supplied, or that a reform " + "makes other than the award the two parts of the year add up to, " + "carries no dates, so the restriction applies to it, and to the rest " + "of the benefit unit's, for the whole year." ) definition_period = YEAR unit = GBP @@ -43,10 +47,13 @@ def formula(person, period, parameters): if len(parts) == 1 or supplied is not None: return whole_year # In the year the earnings taper starts, a person keeps their award - # for the parts of the year the restriction picks them in. - candidate_share, kept_share = carers_allowance_shares_of_year( + # for the parts of the year the restriction picks them in, where every + # award in the benefit unit is the one its parts add up to. + _, candidate_share, kept_share = carers_allowance_shares_of_year( person, period, parts ) - has_candidate = candidate_share > 0 - kept = kept_share / where(has_candidate, candidate_share, 1) - return where(has_candidate, candidate * kept, whole_year) + by_parts = person.benunit.all( + is_the_parts_award(candidate, carers_allowance_annual_award(parts)) + ) & (candidate_share > 0) + kept = kept_share / where(by_parts, candidate_share, 1) + return where(by_parts, candidate * kept, whole_year) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py index 6eb04b4942..ea596f8741 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py @@ -12,7 +12,11 @@ class pension_tax_allowance(Variable): "in the tax year, from 2027-28 to 2029-30. The full allowance is 20% of " "the full new State Pension above the Personal Allowance, rounded up to " "the pound (£94 in 2027-28). It falls by £1 for every £3 of total " - "income above the full new State Pension. It is non-refundable: it is " + "income above the full new State Pension, which is the tax year's 52 " + "weeks at the weekly rate, so it follows any change to that rate. The " + "rate is projected unrounded, so the threshold is £13,036.97 in " + "2027-28 where HM Treasury's explainer gives £13,036.40. It is " + "non-refundable: it is " "deducted at Step 6 after the reductions listed before it in " "income_tax_subtractions, only so far as there is Step 5 tax left, " "and it does not change the Personal Allowance." @@ -37,7 +41,11 @@ class pension_tax_allowance(Variable): def formula(person, period, parameters): income_tax = parameters(period).gov.hmrc.income_tax p = income_tax.reliefs.pension_tax_allowance - threshold = p.state_pension_threshold + # The explainer's threshold is the full annual value of the new State + # Pension, "the total income you are entitled to receive within the + # tax year": 52 weeks at the weekly rate. + new_state_pension = parameters(period).gov.dwp.state_pension.new_state_pension + threshold = WEEKS_IN_YEAR * new_state_pension.amount personal_allowance = income_tax.allowances.personal_allowance.amount # The explainer rounds 20% of £466.40 (£93.28) up to £94, and tapers # from £94: Jonathan, £150 above the threshold, gets £94 - £50 = £44. From f51c6dc7bf3d75d2b4cdea7d64b2931b5b181002 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:05:45 -0400 Subject: [PATCH 20/29] Pin the 2028 guarantee credit boundary with and without the Pension Tax Allowance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's cliff probe froze a £1.01 step for current law in 2028. The Budget 2026 Pension Tax Allowance takes the claimant out of Income Tax at the boundary, and Pension Credit counts income net of tax, so guarantee credit now ends at £13,165 of private pension rather than £13,315 and the step is £1.61. Main's value is kept as the case with the allowance off. Co-Authored-By: Claude Opus 5.5 --- ...ion_credit_guarantee_reform_projections.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index dbc2a04920..86d57a6820 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -639,14 +639,30 @@ def test_zero_phase_in_modifier_preserves_a_reformed_own_threshold(): ) +PENSION_TAX_ALLOWANCE = "gov.hmrc.income_tax.reliefs.pension_tax_allowance.in_effect" + + @pytest.mark.parametrize( - "weekly_guarantee,expected_threshold,expected_allowance,expected_step", - [(None, 221.585186, 272.195717, 1.01), (300, 268.368418, 318.978949, 1.38)], + "weekly_guarantee,pension_tax_allowance,expected_threshold,expected_allowance,expected_step", + [ + # From 2027-28 the Budget 2026 Pension Tax Allowance takes the + # claimant out of Income Tax at the boundary, and Pension Credit + # counts income net of tax, so guarantee credit ends at £13,165 of + # private pension rather than £13,315. + (None, True, 221.585186, 272.195717, 1.61), + (None, False, 221.585186, 272.195717, 1.01), + (300, True, 268.368418, 318.978949, 1.38), + ], ) def test_guarantee_credit_boundary_matches_main( - weekly_guarantee, expected_threshold, expected_allowance, expected_step + weekly_guarantee, + pension_tax_allowance, + expected_threshold, + expected_allowance, + expected_step, ): - """Frozen origin/main (6030cbc0b) cliff-probe values, including current law.""" + """Frozen origin/main (6030cbc0b) cliff-probe values, including current law + without the Pension Tax Allowance.""" year = 2028 situation = { "people": {"claimant": {"age": {year: 85}}}, @@ -674,10 +690,12 @@ def test_guarantee_credit_boundary_matches_main( ] ], } - changes = {f"{GUARANTEE}.SINGLE": {"2026-01-01.2100-12-31": weekly_guarantee}} - simulation = Simulation( - situation=situation, reform=changes if weekly_guarantee is not None else None - ) + changes = {} + if weekly_guarantee is not None: + changes[f"{GUARANTEE}.SINGLE"] = {"2026-01-01.2100-12-31": weekly_guarantee} + if not pension_tax_allowance: + changes[PENSION_TAX_ALLOWANCE] = {"2026-01-01.2100-12-31": False} + simulation = Simulation(situation=situation, reform=changes or None) parameters = simulation.tax_benefit_system.parameters assert parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( "2028-04-30" From bffa9e8bfd459fee3d3b1b9645a548f5a0154682 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 20:13:10 -0400 Subject: [PATCH 21/29] Tell a Carer's Allowance stage's origin by its formula, not its value In 2030-31 an amount before the couple restriction or the overlap rule carries the parts of the year only where the simulation calculates it with the model's own formula: no input supplies it, and no reform neutralizes it or puts a formula of its own in its place. The payment takes the same decision from both stages, so a reform that changes only the partner's award before the couple limit gets the whole year's restriction and the whole year's overlap for both partners, and a reform amount equal to the model's no longer passes as the model's. A reform that updates a stage but keeps its formula keeps the parts of the year. calculated_by moves from the benefit cap variable to utils/formula_origin.py so both can use it. The Pension Tax Allowance YAML header gives the taper's end unrounded (13,318.973), so 13,318.98 in whole pence. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 29 +-- .../pension_tax_allowance.yaml | 3 +- .../test_carers_allowance_earnings_taper.py | 208 +++++++++++++++++- .../utils/carers_allowance_taper.py | 41 ++-- policyengine_uk/utils/formula_origin.py | 20 ++ .../variables/gov/dwp/carers_allowance.py | 61 +++-- .../gov/dwp/carers_allowance_pre_overlap.py | 33 ++- ...uc_benefit_cap_exempt_specified_benefit.py | 20 +- 9 files changed, 316 insertions(+), 101 deletions(-) create mode 100644 policyengine_uk/utils/formula_origin.py diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 9ead549dde..fe4e56a420 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. Amounts supplied, neutralized or replaced by a reform before the couple restriction or the overlap rule reach the payment, with the whole year's restriction and overlap, and a payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes or calculates with a formula of its own, reaches the payment with the whole year's restriction and overlap, even where it equals the model's. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index 076f34571e..fec66038fa 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -101,19 +101,22 @@ the weeks under it. A partner below the earnings limit then keeps the award for the weeks before the other becomes eligible. The parts of the year reach the payment as shares of the year: `carers_allowance_pre_overlap` is the amount from `carers_allowance_pre_couple_limit` that the restriction keeps, -and overlapping benefits come off for the share of the year it is kept. - -The shares describe an amount only where it is the one the parts of the year -add up to, to the penny. A `carers_allowance_pre_couple_limit` that is -supplied, or that a reform sets to anything else, carries no dates: the -couple restriction applies to the benefit unit's awards for the whole year, -and the whole year's overlapping benefits come off what it keeps. A -`carers_allowance_pre_overlap` that is supplied or set by a reform is past -the couple restriction, so only the whole year's overlapping benefits come -off it. Either way, the value supplied or set is what the payment starts -from. Less than half a penny for the year, which in practice is -floating-point residue where an overlapping benefit equals the award, is -treated as nil; a penny is paid. +and overlapping benefits come off for the share of the year in which the +restriction keeps it. + +The shares describe an amount only where the model's own formulas calculate +it from the parts of the year. An amount that an input supplies, or that a +reform neutralizes or calculates with a formula of its own, carries no dates, +even where it equals the model's: a value cannot show where it came from. A +`carers_allowance_pre_couple_limit` of that kind gets the couple restriction +for the whole year, and the whole year's overlapping benefits come off what +the restriction keeps. A `carers_allowance_pre_overlap` of that kind comes +after the couple restriction, so only the whole year's overlapping benefits +come off it. Either way, the payment starts from that value. +A reform that updates either variable but keeps the model's formula keeps +the parts of the year. The model treats less than half a penny for the year, +which in practice is floating-point residue where an overlapping benefit +equals the award, as nil, and pays a penny. The convention does not maximise the couple's payment. It can select a partner whose overlapping benefits reduce CA or CSP to zero, while suppressing a diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml index adba46cb97..6261114e31 100644 --- a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml @@ -10,7 +10,8 @@ # projects the April 2027 rate unrounded, 241.30 x 1.039 = 250.71, so the # threshold is 13,036.97, where HM Treasury's explainer gives 13,036.40 # (250.70 a week). The full allowance is 94 on either: 20% x (13,036.97 - -# 12,570) = 93.39. It is gone from 13,036.97 + 3 x 94 = 13,318.97 (the +# 12,570) = 93.39. The allowance runs out at 13,036.973 + 3 x 94 = +# 13,318.973, so 13,318.98 is the first income in whole pence with none (the # explainer's "13,319 or more"). # # The first five cases are the worked examples in HM Treasury's Pension Tax diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index a5da1f507b..388dda367a 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -16,8 +16,12 @@ - in 2030-31 the allowance paid is the weekly overlap rule over the weeks it is paid, for single carers and for couples caring for the same person, where the couple restriction picks a partner separately before and under the taper; -- an award supplied, neutralized or replaced by a reform before the couple - limit or the overlap rule is what reaches the payment; +- an award that an input supplies, or that a reform neutralizes or calculates + with a formula of its own, before the couple limit or the overlap rule is + what reaches the payment, with the whole year's restriction and overlap, + even where it equals the model's; so 2030-31 pays such an award as + 2031-32 does, while a reform that keeps the model's formulas keeps the + parts of the year; - an overlapping benefit equal to the award leaves nothing paid or received, a penny left over is paid, and anything paid is at least half a penny. """ @@ -490,6 +494,206 @@ def test_a_reform_replacing_the_award_before_overlap_meets_the_whole_years_pensi assert paid == pytest.approx([600, 0], abs=0.01) +def reformed(name, change): + """A reform whose formula for a variable changes the model's amounts.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables[name]) + model = variable.formula + + class Reformed(variable): + def formula(person, period, parameters): + return change(model(person, period, parameters)) + + Reformed.__name__ = name + + class Reform_(Reform): + def apply(self): + self.update_variable(Reformed) + + return Reform_ + + +def relabelled(*names): + """A reform that updates variables but keeps the model's formulas.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + classes = [ + type(name, (type(base.variables[name]),), {"label": f"{name}, relabelled"}) + for name in names + ] + + class Relabel(Reform): + def apply(self): + for variable in classes: + self.update_variable(variable) + + return Relabel + + +def couple_with_pension(year): + # The head is £46 a week over the earnings limit and the partner within + # it, each with £400 a year of State Pension. + return both_caring_couple(year, 400 / 52, head_weekly_excess=46) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "change", + [ + lambda award: award * np.array([1, 0]), + lambda award: award * np.array([1, 1.0001]), + lambda award: award + np.array([0, 0.01]), + ], + ids=["partner_nil", "partner_scaled", "partner_penny"], +) +def test_a_reform_changing_only_the_partners_award_meets_the_whole_years_pension( + year, change +): + # A reform's formula for the award before the couple limit carries no + # dates, even where it leaves the head's amount as the model's: the + # restriction keeps the head's award for the whole year, and the whole + # year's State Pension comes off it. + model = Simulation(situation=couple_with_pension(year)) + head = float(model.calculate("carers_allowance_pre_couple_limit", year)[0]) + reform = reformed("carers_allowance_pre_couple_limit", change) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + assert pre_overlap == pytest.approx([head, 0]) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([head - 400, 0], abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_setting_the_models_own_award_before_overlap_meets_the_whole_years_pension( + year, +): + # A value can't show where it came from: an award a reform sets carries + # no dates even where it equals the model's. + model = Simulation(situation=couple_with_pension(year)) + award = model.calculate("carers_allowance_pre_overlap", year).astype(float) + reform = replaced("carers_allowance_pre_overlap", award) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx(np.maximum(award - 400, 0), abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_keeping_the_models_formulas_keeps_the_parts_of_the_year(year): + model = Simulation(situation=couple_with_pension(year)) + expected = model.calculate("carers_allowance", year).astype(float) + reform = relabelled( + "carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap" + ) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + assert sim.calculate("carers_allowance", year).astype(float) == pytest.approx( + expected + ) + if year == START_YEAR: + # The head is paid only under the taper, so less than the whole + # year's pension comes off. + award = float(model.calculate("carers_allowance_pre_overlap", year)[0]) + assert expected[0] > award - 400 + 100 + + +# An award a reform sets before the couple limit carries no dates, so 2030-31 +# restricts it and takes overlapping benefits off it as 2031-32 does, picking +# the head where the head has an award and the partner otherwise. + +reform_award = st.one_of( + st.just(0.0), st.floats(min_value=0.01, max_value=6_000).map(lambda x: round(x, 2)) +) +reform_award_cases = st.lists( + st.tuples( + reform_award, + reform_award, + st.floats(min_value=0, max_value=150), + st.floats(min_value=0, max_value=150), + ), + min_size=CASES, + max_size=CASES, +) + + +def reform_award_situation(): + people, benunits, households = {}, {}, {} + years = (START_YEAR, FULL_YEAR) + for i in range(CASES): + members = [f"head_{i}", f"partner_{i}"] + for name, age in zip(members, (71, 70)): + people[name] = { + "age": {year: age + year - START_YEAR for year in years}, + "care_hours": {year: 40 for year in years}, + } + benunits[f"benunit_{i}"] = { + "members": members, + "would_claim_uc": {year: False for year in years}, + } + households[f"household_{i}"] = { + "members": members, + "country": {year: "ENGLAND" for year in years}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture(scope="module") +def reform_award_sim(): + from policyengine_core.reforms import Reform + from policyengine_uk.model_api import YEAR, Person, Variable + + class award_set_by_reform(Variable): + value_type = float + entity = Person + label = "Carer's Allowance before the couple limit, as a reform sets it" + definition_period = YEAR + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables["carers_allowance_pre_couple_limit"]) + + class carers_allowance_pre_couple_limit(variable): + def formula(person, period, parameters): + return person("award_set_by_reform", period) + + class SetAward(Reform): + def apply(self): + self.add_variable(award_set_by_reform) + self.update_variable(carers_allowance_pre_couple_limit) + + return Simulation(situation=reform_award_situation(), reform=SetAward) + + +@SETTINGS +@given(reform_award_cases) +def test_2030_31_pays_an_award_a_reform_sets_as_2031_32_does(reform_award_sim, drawn): + head_award, partner_award, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + award = np.column_stack([head_award, partner_award]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + sim = reform_award_sim + for year in (START_YEAR, FULL_YEAR): + sim.set_input("award_set_by_reform", year, award) + sim.set_input("state_pension", year, 52 * pension) + sim.reset_calculations() + paid = { + year: sim.calculate("carers_allowance", year).astype(float).reshape(-1, 2) + for year in (START_YEAR, FULL_YEAR) + } + assert paid[START_YEAR] == pytest.approx(paid[FULL_YEAR], abs=0.01) + for year in (START_YEAR, FULL_YEAR): + assert sim.calculate( + "partners_care_for_same_severely_disabled_person", year + ).all() + award = award.reshape(-1, 2) + head = award[:, 0] > 0 + kept = np.column_stack([head, ~head]) * award + expected = np.maximum(0, kept - 52 * pension.reshape(-1, 2)) + expected = np.where(expected < 0.005, 0, expected) + assert paid[START_YEAR] == pytest.approx(expected, abs=0.01) + + @pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) @pytest.mark.parametrize("award", [1_000.01, 5_000.01]) def test_a_penny_left_after_overlap_is_paid(year, award): diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index d19b6adf74..3b0d119d40 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -15,19 +15,19 @@ pre-overlap award is the candidate award scaled by the share the couple restriction keeps, and the overlapping benefits come off for that share. -The shares describe an annual award only where it is the one the parts add -up to. An award supplied as an input or set by a reform carries no dates, so -it gets the whole-year treatment. +The shares describe an annual award only where the model's own formulas +calculate it from the parts of the year. An award that an input supplies, or +that a reform neutralizes or calculates with a formula of its own, carries no +dates, even where it equals the model's: it gets the whole-year treatment, and +so does every stage after it. """ import numpy as np from policyengine_core.commons.formulas import WEEKS_IN_YEAR from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner - -# Annual amounts are float32, which at these sizes is good to about a -# thousandth of a pound, so a penny tells the parts' own award from another. -PENNY = 0.01 +from policyengine_uk.utils.formula_origin import calculated_by +from policyengine_uk.utils.supplied_inputs import supplied_input def carers_allowance_weekly_by_part_of_year(person, period, parameters): @@ -74,19 +74,26 @@ def carers_allowance_annual_award(parts): def carers_allowance_shares_of_year(person, period, parts): - """From carers_allowance_weekly_by_part_of_year's parts: the annual award - the couple restriction keeps, the share of the year in which the person - has an award before the restriction, and the share in which the - restriction leaves it with them.""" - kept_award = candidate = kept = 0 + """From carers_allowance_weekly_by_part_of_year's parts: the share of the + year in which the person has an award before the couple restriction, and + the share in which the restriction leaves it with them.""" + candidate = kept = 0 for share, weekly in parts: selected = limit_carer_benefit_to_one_partner(person, period, weekly) - kept_award = kept_award + share * selected * WEEKS_IN_YEAR candidate = candidate + share * (weekly > 0) kept = kept + share * (selected > 0) - return kept_award, candidate, kept + return candidate, kept -def is_the_parts_award(amount, award): - """Whether an annual amount is the award the parts give, to the penny.""" - return np.abs(amount - award) < PENNY +def calculated_from_parts_of_year(person, period, model_variable): + """Whether the simulation calculates model_variable, a stage before the + payment, with the model's own formula, and so from the parts of the + year: no input supplies it, and no reform neutralizes it or puts a + formula of its own in place of the model's.""" + name = model_variable.__name__ + variable = person.simulation.tax_benefit_system.get_variable(name) + return ( + supplied_input(person, name, period) is None + and not variable.is_neutralized + and calculated_by(person, name, period, model_variable) + ) diff --git a/policyengine_uk/utils/formula_origin.py b/policyengine_uk/utils/formula_origin.py new file mode 100644 index 0000000000..1d9ee46380 --- /dev/null +++ b/policyengine_uk/utils/formula_origin.py @@ -0,0 +1,20 @@ +"""Whether a simulation calculates a variable with the model's own formula.""" + +import os + + +def calculated_by(population, variable_name, period, model_variable): + """Whether the simulation calculates ``variable_name`` with the model's own + 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 = lcwra_formula_origin(variable.get_formula(period), period) + code = getattr(formula, "__code__", None) + model = model_variable.formula.__code__ + return code is not None and ( + os.path.realpath(code.co_filename), + code.co_firstlineno, + ) == (os.path.realpath(model.co_filename), model.co_firstlineno) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 4bfb3e0555..60451e0924 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -1,10 +1,16 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_unit import HALF_PENNY from policyengine_uk.utils.carers_allowance_taper import ( + calculated_from_parts_of_year, carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, - is_the_parts_award, ) -from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( + carers_allowance_pre_couple_limit, +) +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_overlap import ( + carers_allowance_pre_overlap, +) class carers_allowance(Variable): @@ -16,18 +22,18 @@ class carers_allowance(Variable): "the allowance is reduced by any other personal benefit that overlaps " "with it, such as State Pension, and only the balance is paid. The " "comparison is made on annual amounts, which equals the weekly rule " - "when the overlapping benefit is paid at a constant rate all year. " - "Where the overlapping benefit is above the carer rate in some weeks " - "and lower or absent in others, for example when it is paid for part " - "of the year, the annual comparison pays less than the weekly rule " - "would; annual inputs carry no dates to do better. In the year the " - "Budget 2026 earnings taper starts, overlapping benefits come off " - "only for the share of the year the allowance is paid in. A " + "when the overlapping benefit pays a constant rate all year. Where " + "the overlapping benefit exceeds the carer rate in some weeks and " + "falls short of it or stops in others, for example when it starts " + "partway through the year, the annual comparison pays less than the " + "weekly rule would; annual inputs carry no dates to do better. In the " + "year the Budget 2026 earnings taper starts, overlapping benefits " + "come off only for the share of the year the allowance pays for. A " "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " - "that is supplied, or that a reform makes other than the award the " - "parts of the year give, carries no dates, so the whole year's " - "overlapping benefits come off it. Less than half a penny for the " - "year is nil." + "that an input supplies, or that a reform neutralizes or calculates " + "with a formula of its own, carries no dates, even where it equals " + "the model's, so the whole year's overlapping benefits come off the " + "award. Less than half a penny for the year is nil." ) definition_period = YEAR unit = GBP @@ -41,28 +47,21 @@ def formula(person, period, parameters): pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - supplied = any( - supplied_input(person, name, period) is not None - for name in ( - "carers_allowance_pre_overlap", - "carers_allowance_pre_couple_limit", + by_parts = len(parts) > 1 and all( + calculated_from_parts_of_year(person, period, stage) + for stage in ( + carers_allowance_pre_couple_limit, + carers_allowance_pre_overlap, ) ) - if len(parts) == 1 or supplied: - share_paid = 1 - else: + if by_parts: # The overlap rule works week by week, so in the year the # earnings taper starts, the overlapping benefits come off only - # for the share of the year the person is paid in, where the - # pre-overlap award is the one the parts of the year give. - kept_award, candidate_share, kept_share = carers_allowance_shares_of_year( - person, period, parts - ) - by_parts = is_the_parts_award(pre_overlap, kept_award) & ( - candidate_share > 0 - ) - share_paid = where(by_parts, kept_share, 1) + # for the share of the year the person keeps the award in. + _, share_paid = carers_allowance_shares_of_year(person, period, parts) + else: + share_paid = 1 paid = max_(pre_overlap - share_paid * overlapping, 0) # Where an overlapping benefit equals the award, float32 leaves about a # thousandth of a pound, which is not a payment; a penny is. - return where(paid < 0.005, 0, paid) + return where(paid < HALF_PENNY, 0, paid) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 46971e5cf8..98638870fe 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,12 +1,13 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( - carers_allowance_annual_award, + calculated_from_parts_of_year, carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, - is_the_parts_award, ) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner -from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( + carers_allowance_pre_couple_limit, +) class carers_allowance_pre_overlap(Variable): @@ -27,10 +28,10 @@ class carers_allowance_pre_overlap(Variable): "picks a partner separately for the weeks before the taper and the " "weeks under it, so a partner below the earnings limit keeps the " "award for the weeks before the other becomes eligible. A " - "carers_allowance_pre_couple_limit that is supplied, or that a reform " - "makes other than the award the two parts of the year add up to, " - "carries no dates, so the restriction applies to it, and to the rest " - "of the benefit unit's, for the whole year." + "carers_allowance_pre_couple_limit that an input supplies, or that a " + "reform neutralizes or calculates with a formula of its own, carries " + "no dates, even where it equals the model's, so the restriction " + "applies to it for the whole year." ) definition_period = YEAR unit = GBP @@ -43,17 +44,15 @@ def formula(person, period, parameters): candidate = person("carers_allowance_pre_couple_limit", period) whole_year = limit_carer_benefit_to_one_partner(person, period, candidate) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - supplied = supplied_input(person, "carers_allowance_pre_couple_limit", period) - if len(parts) == 1 or supplied is not None: + if len(parts) == 1 or not calculated_from_parts_of_year( + person, period, carers_allowance_pre_couple_limit + ): return whole_year # In the year the earnings taper starts, a person keeps their award - # for the parts of the year the restriction picks them in, where every - # award in the benefit unit is the one its parts add up to. - _, candidate_share, kept_share = carers_allowance_shares_of_year( + # for the parts of the year the restriction picks them in. + candidate_share, kept_share = carers_allowance_shares_of_year( person, period, parts ) - by_parts = person.benunit.all( - is_the_parts_award(candidate, carers_allowance_annual_award(parts)) - ) & (candidate_share > 0) - kept = kept_share / where(by_parts, candidate_share, 1) - return where(by_parts, candidate * kept, whole_year) + has_award = candidate_share > 0 + kept = kept_share / where(has_award, candidate_share, 1) + return where(has_award, candidate * kept, whole_year) 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 d752aad48f..a0d689c3c7 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 @@ -1,7 +1,6 @@ -import os - from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import award_of_couple +from policyengine_uk.utils.formula_origin import calculated_by from policyengine_uk.utils.supplied_inputs import supplied_input from policyengine_uk.utils.uc_work_related_requirements import ( other_member_of_single_claim, @@ -49,23 +48,6 @@ ] -def calculated_by(population, variable_name, period, model_variable): - """Whether the simulation calculates ``variable_name`` with the model's own - 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 = lcwra_formula_origin(variable.get_formula(period), period) - code = getattr(formula, "__code__", None) - model = model_variable.formula.__code__ - return code is not None and ( - os.path.realpath(code.co_filename), - code.co_firstlineno, - ) == (os.path.realpath(model.co_filename), model.co_firstlineno) - - class is_uc_benefit_cap_exempt_specified_benefit(Variable): value_type = bool entity = BenUnit From 7279bfb76382bde145af3547a0746713a6291e31 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 21:19:34 -0400 Subject: [PATCH 22/29] Keep a Carer's Allowance stage's dates only where formula and amount match Review round 6 found that a reform can keep the model's formula and add defined_for, which changes the amount but not the formula. The formula test alone then trusted the parts of the year and erased an eligible partner's award before the overlap rule. carries_parts_of_year now asks both: does the simulation calculate the stage with the model's formula, and does it equal, for every member of the benefit unit, what the parts of the year give, to half a penny. The masking case is a regression test. Also answers round 6's wording notes: the changelog says the remaining stages get the whole year's treatment, the assumptions page states the value condition and the per-variable limitation, and formula_origin's docstring is active. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 22 ++++---- .../test_carers_allowance_earnings_taper.py | 53 +++++++++++++------ .../utils/carers_allowance_taper.py | 30 +++++++---- policyengine_uk/utils/formula_origin.py | 4 +- .../variables/gov/dwp/carers_allowance.py | 44 +++++++++------ .../gov/dwp/carers_allowance_pre_overlap.py | 25 +++++---- 7 files changed, 116 insertions(+), 64 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index fe4e56a420..c52df5e34c 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes or calculates with a formula of its own, reaches the payment with the whole year's restriction and overlap, even where it equals the model's. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index fec66038fa..f6e4eb8de9 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -105,16 +105,20 @@ and overlapping benefits come off for the share of the year in which the restriction keeps it. The shares describe an amount only where the model's own formulas calculate -it from the parts of the year. An amount that an input supplies, or that a +it from the parts of the year and, for every member of the benefit unit, it +is the amount the parts give. An amount that an input supplies, or that a reform neutralizes or calculates with a formula of its own, carries no dates, -even where it equals the model's: a value cannot show where it came from. A -`carers_allowance_pre_couple_limit` of that kind gets the couple restriction -for the whole year, and the whole year's overlapping benefits come off what -the restriction keeps. A `carers_allowance_pre_overlap` of that kind comes -after the couple restriction, so only the whole year's overlapping benefits -come off it. Either way, the payment starts from that value. -A reform that updates either variable but keeps the model's formula keeps -the parts of the year. The model treats less than half a penny for the year, +even where it equals the model's: a value cannot show where it came from. So +does an amount that a reform changes while keeping the formula, for example +by adding `defined_for`. A `carers_allowance_pre_couple_limit` of that kind +gets the couple restriction for the whole year, and the whole year's +overlapping benefits come off what the restriction keeps. A +`carers_allowance_pre_overlap` of that kind comes after the couple +restriction, so only the whole year's overlapping benefits come off it. +Either way, the payment starts from that value. A reform that changes only a +variable's label, say, keeps the parts of the year. The test of the formula +covers the whole variable, so a reform that wraps the formula to change one +person's amount moves every amount to the whole-year treatment. The model treats less than half a penny for the year, which in practice is floating-point residue where an overlapping benefit equals the award, as nil, and pays a penny. diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 388dda367a..e5a810b577 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -16,12 +16,12 @@ - in 2030-31 the allowance paid is the weekly overlap rule over the weeks it is paid, for single carers and for couples caring for the same person, where the couple restriction picks a partner separately before and under the taper; -- an award that an input supplies, or that a reform neutralizes or calculates - with a formula of its own, before the couple limit or the overlap rule is - what reaches the payment, with the whole year's restriction and overlap, - even where it equals the model's; so 2030-31 pays such an award as - 2031-32 does, while a reform that keeps the model's formulas keeps the - parts of the year; +- an award that an input supplies, or that a reform neutralizes, calculates + with a formula of its own or masks with defined_for, before the couple + limit or the overlap rule is what reaches the payment, with the whole + year's treatment, even where it equals the model's; so 2030-31 pays such + an award as 2031-32 does, while a reform that changes only a label keeps + the parts of the year; - an overlapping benefit equal to the award leaves nothing paid or received, a penny left over is paid, and anything paid is at least half a penny. """ @@ -515,22 +515,19 @@ def apply(self): return Reform_ -def relabelled(*names): - """A reform that updates variables but keeps the model's formulas.""" +def updated(names, attributes): + """A reform that updates variables' attributes but keeps their formulas.""" from policyengine_core.reforms import Reform base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system - classes = [ - type(name, (type(base.variables[name]),), {"label": f"{name}, relabelled"}) - for name in names - ] + classes = [type(name, (type(base.variables[name]),), attributes) for name in names] - class Relabel(Reform): + class Update(Reform): def apply(self): for variable in classes: self.update_variable(variable) - return Relabel + return Update def couple_with_pension(year): @@ -584,8 +581,9 @@ def test_a_reform_setting_the_models_own_award_before_overlap_meets_the_whole_ye def test_a_reform_keeping_the_models_formulas_keeps_the_parts_of_the_year(year): model = Simulation(situation=couple_with_pension(year)) expected = model.calculate("carers_allowance", year).astype(float) - reform = relabelled( - "carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap" + reform = updated( + ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"], + {"label": "relabelled"}, ) sim = Simulation(situation=couple_with_pension(year), reform=reform) assert sim.calculate("carers_allowance", year).astype(float) == pytest.approx( @@ -598,6 +596,29 @@ def test_a_reform_keeping_the_models_formulas_keeps_the_parts_of_the_year(year): assert expected[0] > award - 400 + 100 +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_masking_the_award_before_the_couple_limit_keeps_the_partners( + year, +): + # The reform keeps the model's formula but limits the award to women, so + # only the partner has one. The masked amounts aren't the ones the parts + # of the year give, so the restriction sees them for the whole year and + # leaves the partner's award with them. + reform = updated( + ["carers_allowance_pre_couple_limit"], {"defined_for": "is_female"} + ) + situation = both_caring_couple(year, 400 / 52) + situation["people"]["head"]["is_female"] = {year: False} + situation["people"]["partner"]["is_female"] = {year: True} + sim = Simulation(situation=situation, reform=reform) + award = sim.calculate("carers_allowance_pre_couple_limit", year).astype(float) + assert award[0] == 0 and award[1] > 4_000 + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + assert pre_overlap == pytest.approx(award) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([0, award[1] - 400], abs=0.01) + + # An award a reform sets before the couple limit carries no dates, so 2030-31 # restricts it and takes overlapping benefits off it as 2031-32 does, picking # the head where the head has an award and the partner otherwise. diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index 3b0d119d40..baa1fdcddc 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -16,15 +16,19 @@ restriction keeps, and the overlapping benefits come off for that share. The shares describe an annual award only where the model's own formulas -calculate it from the parts of the year. An award that an input supplies, or -that a reform neutralizes or calculates with a formula of its own, carries no -dates, even where it equals the model's: it gets the whole-year treatment, and -so does every stage after it. +calculate it from the parts of the year, and only where it is, for every +member of the benefit unit, the amount the parts give. An award that an input +supplies, or that a reform neutralizes or calculates with a formula of its +own, carries no dates even where it equals the model's: a value can't show +where it came from. And a reform that keeps the formula can still change the +amount, for example with defined_for. Either way the award gets the +whole-year treatment, and so does every stage after it. """ import numpy as np from policyengine_core.commons.formulas import WEEKS_IN_YEAR +from policyengine_uk.utils.benefit_unit import HALF_PENNY from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner from policyengine_uk.utils.formula_origin import calculated_by from policyengine_uk.utils.supplied_inputs import supplied_input @@ -85,15 +89,21 @@ def carers_allowance_shares_of_year(person, period, parts): return candidate, kept -def calculated_from_parts_of_year(person, period, model_variable): - """Whether the simulation calculates model_variable, a stage before the - payment, with the model's own formula, and so from the parts of the - year: no input supplies it, and no reform neutralizes it or puts a - formula of its own in place of the model's.""" +def carries_parts_of_year(person, period, model_variable, amount, parts_amount): + """Whether amount, the annual value of model_variable (a stage before the + payment), carries the parts of the year: the simulation calculates it with + the model's own formula (no input supplies it, and no reform neutralizes + it or puts a formula of its own in place of the model's), and for every + member of the benefit unit it is parts_amount, what the parts of the year + give, so nothing else a reform changes, such as defined_for, has changed + it.""" name = model_variable.__name__ variable = person.simulation.tax_benefit_system.get_variable(name) - return ( + by_model = ( supplied_input(person, name, period) is None and not variable.is_neutralized and calculated_by(person, name, period, model_variable) ) + # Annual amounts are float32, good to about a thousandth of a pound. + as_parts = person.benunit.all(np.abs(amount - parts_amount) < HALF_PENNY) + return by_model & as_parts diff --git a/policyengine_uk/utils/formula_origin.py b/policyengine_uk/utils/formula_origin.py index 1d9ee46380..32762115ea 100644 --- a/policyengine_uk/utils/formula_origin.py +++ b/policyengine_uk/utils/formula_origin.py @@ -6,8 +6,8 @@ def calculated_by(population, variable_name, period, model_variable): """Whether the simulation calculates ``variable_name`` with the model's own 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.""" + The tax-benefit system loads each variable file as its own module, so this + matches the formula 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) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 60451e0924..892672e706 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -1,9 +1,10 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import HALF_PENNY from policyengine_uk.utils.carers_allowance_taper import ( - calculated_from_parts_of_year, + carers_allowance_annual_award, carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, + carries_parts_of_year, ) from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( carers_allowance_pre_couple_limit, @@ -30,10 +31,9 @@ class carers_allowance(Variable): "year the Budget 2026 earnings taper starts, overlapping benefits " "come off only for the share of the year the allowance pays for. A " "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " - "that an input supplies, or that a reform neutralizes or calculates " - "with a formula of its own, carries no dates, even where it equals " - "the model's, so the whole year's overlapping benefits come off the " - "award. Less than half a penny for the year is nil." + "that an input supplies, or that a reform neutralizes, calculates " + "with a formula of its own or otherwise changes, carries no dates, so " + "the whole year's overlapping benefits come off the award. Less than half a penny for the year is nil." ) definition_period = YEAR unit = GBP @@ -47,20 +47,32 @@ def formula(person, period, parameters): pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - by_parts = len(parts) > 1 and all( - calculated_from_parts_of_year(person, period, stage) - for stage in ( + if len(parts) == 1: + share_paid = 1 + else: + # The overlap rule works week by week, so in the year the + # earnings taper starts, the overlapping benefits come off only + # for the share of the year the person keeps the award in, where + # both stages before the payment carry the parts of the year. + candidate = person("carers_allowance_pre_couple_limit", period) + candidate_share, kept_share = carers_allowance_shares_of_year( + person, period, parts + ) + kept = kept_share / where(candidate_share > 0, candidate_share, 1) + by_parts = carries_parts_of_year( + person, + period, carers_allowance_pre_couple_limit, + candidate, + carers_allowance_annual_award(parts), + ) & carries_parts_of_year( + person, + period, carers_allowance_pre_overlap, + pre_overlap, + candidate * kept, ) - ) - if by_parts: - # The overlap rule works week by week, so in the year the - # earnings taper starts, the overlapping benefits come off only - # for the share of the year the person keeps the award in. - _, share_paid = carers_allowance_shares_of_year(person, period, parts) - else: - share_paid = 1 + share_paid = where(by_parts, kept_share, 1) paid = max_(pre_overlap - share_paid * overlapping, 0) # Where an overlapping benefit equals the award, float32 leaves about a # thousandth of a pound, which is not a payment; a penny is. diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 98638870fe..a659a2f4ac 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,8 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.carers_allowance_taper import ( - calculated_from_parts_of_year, + carers_allowance_annual_award, carers_allowance_shares_of_year, carers_allowance_weekly_by_part_of_year, + carries_parts_of_year, ) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( @@ -29,9 +30,9 @@ class carers_allowance_pre_overlap(Variable): "weeks under it, so a partner below the earnings limit keeps the " "award for the weeks before the other becomes eligible. A " "carers_allowance_pre_couple_limit that an input supplies, or that a " - "reform neutralizes or calculates with a formula of its own, carries " - "no dates, even where it equals the model's, so the restriction " - "applies to it for the whole year." + "reform neutralizes, calculates with a formula of its own or " + "otherwise changes (with defined_for, say), carries no dates, so the " + "restriction applies to the benefit unit's awards for the whole year." ) definition_period = YEAR unit = GBP @@ -44,15 +45,19 @@ def formula(person, period, parameters): candidate = person("carers_allowance_pre_couple_limit", period) whole_year = limit_carer_benefit_to_one_partner(person, period, candidate) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - if len(parts) == 1 or not calculated_from_parts_of_year( - person, period, carers_allowance_pre_couple_limit - ): + if len(parts) == 1: return whole_year # In the year the earnings taper starts, a person keeps their award # for the parts of the year the restriction picks them in. candidate_share, kept_share = carers_allowance_shares_of_year( person, period, parts ) - has_award = candidate_share > 0 - kept = kept_share / where(has_award, candidate_share, 1) - return where(has_award, candidate * kept, whole_year) + by_parts = carries_parts_of_year( + person, + period, + carers_allowance_pre_couple_limit, + candidate, + carers_allowance_annual_award(parts), + ) & (candidate_share > 0) + kept = kept_share / where(by_parts, candidate_share, 1) + return where(by_parts, candidate * kept, whole_year) From 8db87df6ae6d6a1ba397274eff8e62c14b24723d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 22:16:33 -0400 Subject: [PATCH 23/29] Answer review round 7: a fraction of a penny is an award, and no cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7 found two gaps in round 6's check of a Carer's Allowance stage. Half a penny treated a fraction of a penny as nil, but the couple restriction picks whoever has an award. A reform masking a male head's £0.0026 under the taper kept him picked for those weeks and took £2,301.24 from the partner. matches_parts_of_year now also requires the amount to be positive exactly where the parts of the year give an award. The payment read carers_allowance_pre_couple_limit before checking carers_allowance_pre_overlap, so a reform deriving the earlier stage from the payment, which a supplied pre-overlap award had kept calculable, raised a cycle. The payment now reads the earlier stage only where the model calculates the pre-overlap award (calculated_by_model). Tests: the no-reform fraction of a penny keeps the parts of the year; masking it at either stage gives the whole year; the cycle case pays £600. Also: the merge review's wording fixes (the Pension Credit boundary is a £5 grid point, road_fuel_gases.yaml's header and the road fuel volume comment carry the October rates and the Budget 2026 schedule), and active voice in the PR's docs, descriptions and comments. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 8 +- .../gov/treasury/cost-of-living-payments.md | 14 +-- .../earnings_taper/applies.yaml | 4 +- .../earnings_taper/index.yaml | 2 +- .../gov/economic_assumptions/yoy_growth.yaml | 12 +-- .../gov/hmrc/income_tax/rates/property.yaml | 2 +- .../gov/hmrc/income_tax/rates/savings.yaml | 2 +- .../household_energy_payment/amount.yaml | 6 +- .../gov/hmrc/fuel_duty/road_fuel_gases.yaml | 5 +- .../test_carers_allowance_earnings_taper.py | 89 ++++++++++++++++++- ...ion_credit_guarantee_reform_projections.py | 6 +- .../utils/carers_allowance_taper.py | 52 +++++++---- .../variables/gov/dwp/carers_allowance.py | 13 ++- ...s_allowance_weekly_earnings_above_limit.py | 4 +- .../variables/gov/hmrc/domestic_energy_vat.py | 8 +- .../reliefs/pension_tax_allowance.py | 2 +- .../reliefs/property_finance_cost_relief.py | 6 +- .../household_energy_payment.py | 10 +-- 19 files changed, 182 insertions(+), 65 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index c52df5e34c..1633546c2c 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks it is paid, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction picks whoever has an award. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index f6e4eb8de9..663adab1f6 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -106,7 +106,9 @@ restriction keeps it. The shares describe an amount only where the model's own formulas calculate it from the parts of the year and, for every member of the benefit unit, it -is the amount the parts give. An amount that an input supplies, or that a +is the amount the parts give: within half a penny, and an award, however +small, exactly where the parts give one, since the couple restriction picks +whoever has an award. An amount that an input supplies, or that a reform neutralizes or calculates with a formula of its own, carries no dates, even where it equals the model's: a value cannot show where it came from. So does an amount that a reform changes while keeping the formula, for example @@ -115,7 +117,9 @@ gets the couple restriction for the whole year, and the whole year's overlapping benefits come off what the restriction keeps. A `carers_allowance_pre_overlap` of that kind comes after the couple restriction, so only the whole year's overlapping benefits come off it. -Either way, the payment starts from that value. A reform that changes only a +Either way, the payment starts from that value, and where the +`carers_allowance_pre_overlap` is of that kind, the payment doesn't read the +stage before it. A reform that changes only a variable's label, say, keeps the parts of the year. The test of the formula covers the whole variable, so a reform that wraps the formula to change one person's amount moves every amount to the whole-year treatment. The model treats less than half a penny for the year, diff --git a/docs/book/programs/gov/treasury/cost-of-living-payments.md b/docs/book/programs/gov/treasury/cost-of-living-payments.md index 3c2d18ccd8..901aae27a9 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -31,8 +31,8 @@ PolicyEngine models these via: ## Household Energy Payment (2026-27) Budget 2026 (paragraph 6.24) announced a one-off £100 Household Energy -Payment for households in council tax bands A to D in England, paid into -bank accounts by local authorities from January 2027. PolicyEngine models it as +Payment for households in council tax bands A to D in England, which local +authorities pay into bank accounts from January 2027. PolicyEngine models it as `household_energy_payment` (Household, YEAR), with parameters at `parameters/gov/treasury/household_energy_payment/`: @@ -46,17 +46,17 @@ Rebate does, and in `hbai_household_net_income`: HBAI counted the 2022 Council Tax Rebate and the Energy Bills Support Scheme as miscellaneous income ([HBAI quality and methodology report FYE 2023](https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2023/households-below-average-income-series-quality-and-methodology-information-report-fye-2023)). The devolved governments receive a Barnett share instead, which the model -does not allocate, and the £100 million of further local-authority funding -for targeted support is not modelled. +does not allocate, and the model leaves out the £100 million of further +local-authority funding for targeted support. The payment goes to the householder [liable for council tax on their main home](https://www.gov.uk/government/news/what-the-autumn-budget-2026-means-for-your-energy-bills), including most households exempt from council tax; full criteria are to follow. The model pays every household in a qualifying band. Survey data hold only main homes, so the second homes and empty properties the scheme -excludes do not arise. Households whose landlord is liable (council tax in -the rent) cannot be identified, so they are paid, which slightly overstates -recipients. `council_tax_band` defaults to band D, so a household with no +excludes do not arise. The model can't tell which households have a +landlord liable for council tax (council tax in the rent), so it pays them, +which slightly overstates recipients. `council_tax_band` defaults to band D, so a household with no band recorded qualifies. ## Cost-of-Living Payments (2022-23 and 2023-24) diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml index 06cafededb..e6e55bc9ea 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml @@ -1,8 +1,8 @@ description: Share of the fiscal year in which Carer's Allowance tapers on earnings above the limit instead of stopping in full. values: # The taper "will begin within the 2030-31 financial year, with a precise - # start date confirmed in due course" (policy costings, page 16). Until the - # date is confirmed, the model starts it on 6 October 2030, the middle of + # start date confirmed in due course" (policy costings, page 16). Until DWP + # confirms the date, the model starts it on 6 October 2030, the middle of # 2030-31. fiscal_year_blend day-weights the year, so 2030-31 carries 182 # of its 365 days. Change the date here when DWP confirms it. 0001-01-01: 0 diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml index 20b788089b..c6ab63bdbc 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml @@ -1,4 +1,4 @@ -description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. It is announced policy, not yet legislated, and DWP will confirm the start date. +description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. It is announced policy, not yet law, and DWP will confirm the start date. metadata: label: Carer's Allowance earnings taper reference: diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index f654017784..1b1bdc7ad4 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -304,12 +304,12 @@ obr: # 2027 (57.95p raised by Q1 2027 RPI growth, OBR economy Table 1.7), # then 63.21p, 65.13p, 67.07p and 68.61p from 1 April 2028 to 2031, # each raised by the previous calendar year's RPI growth in obr.rpi. - # That is the schedule PolicyEngine/policyengine-uk#2719 puts into - # gov.hmrc.fuel_duty.petrol_and_diesel, so once it is law the model's - # duty on these volumes reproduces the OBR's road fuel receipts. Until - # then current law there is the Budget 2025 schedule (60.03p from April - # 2027). The derivation is policyengine-uk-data's - # sources/road_fuel_volume.py. + # That is the schedule in gov.hmrc.fuel_duty.petrol_and_diesel + # (PolicyEngine/policyengine-uk#2719). The OBR's receipts year starts on + # 1 April and the model's on 6 April, so a model year takes in five days + # of the next April's rate, and the model's duty on these volumes comes + # close to the OBR's road fuel receipts rather than matching them. The + # derivation is policyengine-uk-data's sources/road_fuel_volume.py. 2026-01-01: -0.02315665811165113 2027-01-01: -0.030554808261277322 2028-01-01: -0.0397847818644439 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml index 9f08ffb10f..9f81aaac55 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml @@ -1,4 +1,4 @@ -description: Property income tax rates. From April 2027, property income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. The Budget 2026 rise is announced policy, not yet legislated. +description: Property income tax rates. From April 2027, property income tax rates run 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. The Budget 2026 rise is announced policy, not yet law. metadata: label: Property Income Tax rates unit: /1 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml index 3bff5ead26..e3ddca03ae 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml @@ -1,4 +1,4 @@ -description: Savings income tax rates. From April 2027, savings income is taxed at rates 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. The Budget 2026 rise is announced policy, not yet legislated. +description: Savings income tax rates. From April 2027, savings income tax rates run 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. The Budget 2026 rise is announced policy, not yet law. metadata: label: Savings Income Tax rates unit: /1 diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml index 5e6b9589fb..3bf272e6e5 100644 --- a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml @@ -2,9 +2,9 @@ description: One-off Household Energy Payment to each eligible household in the values: 0001-01-01: 0 # Budget 2026 announces £100 for households in council tax bands A to D in - # England, delivered by local authorities from January 2027, within - # 2026-27. The model year runs from 6 April, so the payment is keyed to - # model year 2026 and ends with it. It is a spending decision, not + # England, which local authorities pay from January 2027, within + # 2026-27. The model year runs from 6 April, so the payment starts and ends + # with model year 2026. It is a spending decision, not # legislation. 2026-04-06: value: 100 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml index 43d1bfbdd0..90c2106618 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml @@ -2,8 +2,9 @@ # 1 July 2027 (paragraph 6.211; policy costings page 13), so FY2026 is all at # the cut rates. On 1 July 2027 the full rates return uprated by RPI growth in # the year to 2027 Q1 (4.5565%): LPG 33.05p, natural gas 25.83p a kilogram. -# Each 1 April from 2028 applies the previous calendar year's RPI growth: LPG -# 34.04p and natural gas 26.60p in 2028, then 34.99p and 27.34p in 2029. +# Each 1 April from 2028 applies the previous calendar year's RPI growth, on +# the OBR's October 2026 forecast: LPG 34.48p and natural gas 26.95p in 2028, +# then 35.52p and 27.77p in 2029. # FY2027 (366 days) weights are 86, 275 and 5 days. # Rural relief only reduces the petrol/diesel component. - name: No road fuel gas consumption diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index e5a810b577..52d0c83b03 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -21,7 +21,11 @@ limit or the overlap rule is what reaches the payment, with the whole year's treatment, even where it equals the model's; so 2030-31 pays such an award as 2031-32 does, while a reform that changes only a label keeps - the parts of the year; + the parts of the year; a mask that takes away a fraction of a penny counts, + since the couple restriction picks whoever has an award; +- the payment reads the award before the couple limit only where the model + calculates the award before overlap, so a supplied pre-overlap award needs + nothing before it; - an overlapping benefit equal to the award leaves nothing paid or received, a penny left over is paid, and anything paid is at least half a penny. """ @@ -516,7 +520,8 @@ def apply(self): def updated(names, attributes): - """A reform that updates variables' attributes but keeps their formulas.""" + """A reform that updates variables' attributes, keeping their formulas + unless the attributes give one.""" from policyengine_core.reforms import Reform base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system @@ -619,6 +624,86 @@ def test_a_reform_masking_the_award_before_the_couple_limit_keeps_the_partners( assert paid == pytest.approx([0, award[1] - 400], abs=0.01) +def tiny_award_couple(year): + # The head earns a hundredth of a penny a week short of tapering away the + # whole rate, so under the taper they keep £0.0001 a week, a fraction of + # a penny for the year; the partner is within the limit. Each has £400 a + # year of State Pension. + rate = ca_parameters(year)[1] + return both_caring_couple(year, 400 / 52, head_weekly_excess=rate - 0.0001) + + +def test_a_fraction_of_a_penny_is_an_award_for_the_parts_of_the_year(): + # With no reform, the head's award picks the head for the weeks under the + # taper, and the partner keeps their award for the weeks before it. + sim = Simulation(situation=tiny_award_couple(START_YEAR)) + candidate = sim.calculate("carers_allowance_pre_couple_limit", START_YEAR) + candidate = candidate.astype(float) + assert 0 < candidate[0] < 0.01 and candidate[1] > 4_000 + before_taper = 1 - START_YEAR_SHARE + pre_overlap = sim.calculate("carers_allowance_pre_overlap", START_YEAR) + assert pre_overlap.astype(float) == pytest.approx( + [candidate[0], candidate[1] * before_taper] + ) + paid = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert paid == pytest.approx([0, pre_overlap[1] - before_taper * 400], abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "masked", ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"] +) +def test_a_reform_masking_a_fraction_of_a_penny_gets_the_whole_year(year, masked): + # The reform limits a stage to women, so it takes away the male head's + # fraction of a penny. That is within half a penny of the parts of the + # year, but the couple restriction picks whoever has an award, so the + # masked stage carries no dates and the stages after it work on the whole + # year: the partner keeps any award they have before overlap, less the + # whole year's £400 of State Pension. + reform = updated([masked], {"defined_for": "is_female"}) + situation = tiny_award_couple(year) + situation["people"]["head"]["is_female"] = {year: False} + situation["people"]["partner"]["is_female"] = {year: True} + sim = Simulation(situation=situation, reform=reform) + award = sim.calculate("carers_allowance_pre_couple_limit", year).astype(float) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + if masked == "carers_allowance_pre_couple_limit": + assert award[0] == 0 and award[1] > 4_000 + assert pre_overlap == pytest.approx(award) + assert pre_overlap[0] == 0 + if year == START_YEAR: + assert pre_overlap[1] > 2_000 + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx(np.maximum(pre_overlap - 400, 0), abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_supplied_pre_overlap_award_needs_no_award_before_the_couple_limit(year): + # This reform derives the award before the couple limit from the payment, + # which works only because the supplied pre-overlap award breaks the + # loop. A supplied pre-overlap award carries no dates, so the payment + # doesn't read the stage before it. + situation = carer(0, year, "NORTH_WEST") + situation["people"]["carer"].update( + age={year: 70}, + state_pension={year: 400}, + carers_allowance_pre_overlap={year: 1_000}, + ) + reform = updated( + ["carers_allowance_pre_couple_limit"], + { + "formula": lambda person, period, parameters: ( + person("carers_allowance", period) + + person("carers_allowance_overlapping_benefits", period) + ) + }, + ) + paid = Simulation(situation=situation, reform=reform).calculate( + "carers_allowance", year + ) + assert float(paid[0]) == pytest.approx(600) + + # An award a reform sets before the couple limit carries no dates, so 2030-31 # restricts it and takes overlapping benefits off it as 2031-32 does, picking # the head where the head has an award and the partner otherwise. diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index 93fa7e6dad..a9385d2b8b 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -647,8 +647,10 @@ def test_zero_phase_in_modifier_preserves_a_reformed_own_threshold(): [ # From 2027-28 the Budget 2026 Pension Tax Allowance takes the # claimant out of Income Tax at the boundary, and Pension Credit - # counts income net of tax, so guarantee credit ends at £13,280 of - # private pension rather than £13,460. + # counts income net of tax, so on this £5 grid guarantee credit runs + # out between £13,280 and £13,285 of private pension rather than + # between £13,460 and £13,465. The step is the gain in household net + # income across that £5. (None, True, 223.407952, 274.662918, 1.98), (None, False, 223.407952, 274.662918, 0.87), (300, True, 267.965646, 319.220612, 1.38), diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index baa1fdcddc..7d85f5fe7f 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -10,14 +10,15 @@ A person's weekly award is the same in every part of the year in which it is positive: the full rate wherever they are within the earnings limit, and the -tapered rate, which only the part under the taper pays, where they are above -it. So the annual amounts carry the parts as shares of the year: the +tapered rate, which only the part under the taper pays, where their earnings +exceed it. So the annual amounts carry the parts as shares of the year: the pre-overlap award is the candidate award scaled by the share the couple restriction keeps, and the overlapping benefits come off for that share. The shares describe an annual award only where the model's own formulas calculate it from the parts of the year, and only where it is, for every -member of the benefit unit, the amount the parts give. An award that an input +member of the benefit unit, the amount the parts give, positive exactly where +they give an award. An award that an input supplies, or that a reform neutralizes or calculates with a formula of its own, carries no dates even where it equals the model's: a value can't show where it came from. And a reform that keeps the formula can still change the @@ -36,9 +37,9 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): """The weekly award, before the couple and overlap restrictions, in each - part of the year, as (share of the year, weekly award) pairs. A part with - no share of the year is left out, so outside the year the taper starts in - there is one part.""" + part of the year, as (share of the year, weekly award) pairs. The list leaves + out a part with no share of the year, so outside the year the taper + starts in there is one part.""" ca = parameters(period).gov.dwp.carers_allowance in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" csp_replaces_ca = period.start.year >= 2025 @@ -89,21 +90,36 @@ def carers_allowance_shares_of_year(person, period, parts): return candidate, kept -def carries_parts_of_year(person, period, model_variable, amount, parts_amount): - """Whether amount, the annual value of model_variable (a stage before the - payment), carries the parts of the year: the simulation calculates it with - the model's own formula (no input supplies it, and no reform neutralizes - it or puts a formula of its own in place of the model's), and for every - member of the benefit unit it is parts_amount, what the parts of the year - give, so nothing else a reform changes, such as defined_for, has changed - it.""" +def calculated_by_model(person, period, model_variable): + """Whether the simulation calculates model_variable with the model's own + formula: no input supplies it, and no reform neutralizes it or puts a + formula of its own in place of the model's.""" name = model_variable.__name__ variable = person.simulation.tax_benefit_system.get_variable(name) - by_model = ( + return ( supplied_input(person, name, period) is None and not variable.is_neutralized and calculated_by(person, name, period, model_variable) ) - # Annual amounts are float32, good to about a thousandth of a pound. - as_parts = person.benunit.all(np.abs(amount - parts_amount) < HALF_PENNY) - return by_model & as_parts + + +def matches_parts_of_year(person, amount, parts_amount): + """Whether, for every member of the benefit unit, amount is parts_amount, + what the parts of the year give: within half a penny, since annual + amounts are float32, good to about a thousandth of a pound, and positive + exactly where parts_amount is, since the couple restriction picks a + partner by whether their award is positive.""" + same = (np.abs(amount - parts_amount) < HALF_PENNY) & ( + (amount > 0) == (parts_amount > 0) + ) + return person.benunit.all(same) + + +def carries_parts_of_year(person, period, model_variable, amount, parts_amount): + """Whether amount, the annual value of model_variable (a stage before the + payment), carries the parts of the year: the simulation calculates it with + the model's own formula, and it matches what the parts give, so nothing + else a reform changes, such as defined_for, has changed it.""" + return calculated_by_model(person, period, model_variable) & matches_parts_of_year( + person, amount, parts_amount + ) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 892672e706..1691f61510 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -3,6 +3,7 @@ from policyengine_uk.utils.carers_allowance_taper import ( carers_allowance_annual_award, carers_allowance_shares_of_year, + calculated_by_model, carers_allowance_weekly_by_part_of_year, carries_parts_of_year, ) @@ -33,7 +34,10 @@ class carers_allowance(Variable): "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " "that an input supplies, or that a reform neutralizes, calculates " "with a formula of its own or otherwise changes, carries no dates, so " - "the whole year's overlapping benefits come off the award. Less than half a penny for the year is nil." + "the whole year's overlapping benefits come off the award; the payment " + "reads the stage before the couple restriction only where the model " + "calculates the award before overlap. Less than half a penny for the " + "year is nil." ) definition_period = YEAR unit = GBP @@ -47,7 +51,12 @@ def formula(person, period, parameters): pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) - if len(parts) == 1: + if len(parts) == 1 or not calculated_by_model( + person, period, carers_allowance_pre_overlap + ): + # A pre-overlap award that an input supplies or a reform + # calculates carries no dates, so the payment doesn't read the + # stage before it. share_paid = 1 else: # The overlap rule works week by week, so in the year the diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py index f4717dc09c..8f77427e41 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py @@ -9,7 +9,7 @@ class carers_allowance_weekly_earnings_above_limit(Variable): "How far the person's weekly net earnings exceed the Carer's Allowance " "earnings limit, or zero at or below it. The regulation tests the " "earnings of the preceding week; annual earnings are spread evenly " - "over 52 weeks. A fraction of a penny is disregarded, as regulation 5 " + "over 52 weeks. The model disregards a fraction of a penny, as regulation 5 " "of the Computation of Earnings Regulations requires when that favours " "the claimant. Any excess means the person is gainfully employed; " "where the Budget 2026 earnings taper applies, the allowance tapers " @@ -32,6 +32,6 @@ def formula(person, period, parameters): limit = parameters(period).gov.dwp.carers_allowance.earnings_limit weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR # Disregard a fraction of a penny; the tolerance keeps an exact - # number of pence from being knocked down by floating-point error. + # number of pence safe from floating-point error. weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 return max_(0, weekly_in_pence - limit) diff --git a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py index 9fd88bb3a1..0a9c2b9dc0 100644 --- a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py +++ b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py @@ -29,10 +29,10 @@ def _domestic_energy_vat(household, period, parameters, electricity_rate, gas_ra class domestic_energy_vat(Variable): label = "VAT on domestic electricity and gas" documentation = ( - "VAT on the household's electricity and gas bills. Electricity is " - "charged at its own rate, which is zero in Great Britain from 1 October " - "2026 and in Northern Ireland from 1 April 2027, to 30 June 2027; gas " - "is charged at the reduced rate." + "VAT on the household's electricity and gas bills. Electricity has " + "its own rate, which is zero in Great Britain from 1 October 2026 and " + "in Northern Ireland from 1 April 2027, to 30 June 2027; gas takes the " + "reduced rate." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py index ea596f8741..ed73da7efa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py @@ -14,7 +14,7 @@ class pension_tax_allowance(Variable): "the pound (£94 in 2027-28). It falls by £1 for every £3 of total " "income above the full new State Pension, which is the tax year's 52 " "weeks at the weekly rate, so it follows any change to that rate. The " - "rate is projected unrounded, so the threshold is £13,036.97 in " + "model projects the rate unrounded, so the threshold is £13,036.97 in " "2027-28 where HM Treasury's explainer gives £13,036.40. It is " "non-refundable: it is " "deducted at Step 6 after the reductions listed before it in " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index e87c8cf614..c315df331d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -43,9 +43,9 @@ def formula(person, period, parameters): "property_finance_costs_relieved", period ) # The order of Step 6 reductions does not change their total, so the - # reductions listed before this one come first; it is limited to the - # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the - # High Income Child Benefit Charge, are not reduced. + # reductions listed before this one come first, and the Step 5 tax they + # leave caps it. No reduction comes off Step 7 charges (ITA 2007 + # s. 30), such as the High Income Child Benefit Charge. earlier = earlier_tax_reductions(income_tax, "property_finance_cost_relief") tax_left = max_( 0, diff --git a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py index 02baf84fd1..dbb38bf13a 100644 --- a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py +++ b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py @@ -5,15 +5,15 @@ class household_energy_payment(Variable): label = "Household Energy Payment" documentation = ( "One-off payment to households in the qualifying council tax bands and " - "countries, administered by local authorities and paid into bank " - "accounts. Budget 2026 sets £100 for bands A to D in England, from " + "countries, which local authorities pay into bank accounts. Budget " + "2026 sets £100 for bands A to D in England, from " "January 2027. It goes to the householder liable for council tax on " "their main home, including most households exempt from council tax. " "The model pays every household in a qualifying band: survey data " "hold only main homes, so second homes and empty properties do not " - "arise, but households whose landlord is liable (council tax in the " - "rent) cannot be identified and are paid, which slightly overstates " - "recipients." + "arise, but the model can't tell which households have a landlord " + "liable for council tax (council tax in the rent), so it pays them, " + "which slightly overstates recipients." ) entity = Household definition_period = YEAR From b4289411b9827bf5a1a7fd093e3944de010f5dcd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 01:44:06 -0400 Subject: [PATCH 24/29] Couples elect the larger Carer's Allowance award under the taper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A couple caring for the same person jointly elect which of them has the entitlement (SSCBA 1992 s.70(7ZA)(a)). The data don't record the election, so the couple restriction now keeps the partner with the larger candidate award and breaks ties in #2632's order (reported recipient, head, eldest). Before 2030-31 every positive award is the same rate, so nothing changes. Under the 2030-31 taper the old head-first order could pay a couple less than the cliff edge (head £250/wk, partner £200/wk, £40 State Pension each: £1,742.41 against £2,935.13), and a fraction of a penny for the head took the whole 2031-32 award from the partner. Now one partner holds the award for the year, which also ends the Pension Credit carer addition counting two carers. Enhanced FRS 2024-25 Carer's Allowance totals don't change (£5,260.2m in 2030, £5,396.34m in 2031). Also answers round 8's P3s: a 2031-32 mask case that couldn't fail now asserts the partner's award, passives in the test module and the payment's documentation, the HALF_PENNY comment, the maximising paragraph, and the council tax band sentence. Tests: the taper module (63 passed; 9 fail on 8db87df6a's rule), with a new property that the taper never leaves a couple below the cliff edge. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 71 +++--- .../gov/treasury/cost-of-living-payments.md | 4 +- .../test_carers_allowance_earnings_taper.py | 210 ++++++++++++------ policyengine_uk/utils/benefit_unit.py | 4 +- .../utils/carers_allowance_taper.py | 4 +- policyengine_uk/utils/couple_carer_award.py | 13 +- .../variables/gov/dwp/carers_allowance.py | 12 +- .../gov/dwp/carers_allowance_pre_overlap.py | 16 +- 9 files changed, 219 insertions(+), 117 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 1633546c2c..2e13808ac0 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for, and a partner below the limit keeps the award until the other partner becomes eligible. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction picks whoever has an award. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index 663adab1f6..bf3679bf1b 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -88,47 +88,56 @@ second candidate entitlement before reducing either payment for overlapping benefits. A selected carer whose overlapping benefit removes the payment still retains the underlying entitlement. -The law provides election or decision rules; the model does not observe that -choice. Among otherwise eligible partners, it prefers a reported recipient, -then an eligible benefit-unit head, then the eldest eligible partner. Member -order breaks an age tie. This is a modelling convention, not statutory -priority. An ineligible reported recipient or head cannot displace an eligible -partner. The caring-hours, earnings, age and take-up conditions are unchanged. - -In 2030-31, the year the Budget 2026 Carer's Allowance earnings taper starts, -the model applies this convention separately to the weeks before the taper and -the weeks under it. A partner below the earnings limit then keeps the award -for the weeks before the other becomes eligible. The parts of the year reach -the payment as shares of the year: `carers_allowance_pre_overlap` is the -amount from `carers_allowance_pre_couple_limit` that the restriction keeps, -and overlapping benefits come off for the share of the year in which the +The couple jointly elect which of them has the entitlement; the Secretary of +State decides only where they don't. The data don't record the election, so +the model takes the one that gives the larger award: of the otherwise eligible +partners, the one whose candidate award is larger has the entitlement. The +awards differ only under the Budget 2026 Carer's Allowance earnings taper; +everywhere else each eligible partner has the same rate. Where the awards are +equal, the model prefers a reported recipient, then an eligible benefit-unit +head, then the eldest eligible partner, and member order breaks an age tie. +That order is a modelling convention, not statutory priority. An ineligible +reported recipient or head cannot displace an eligible partner. The +caring-hours, earnings, age and take-up conditions stay as they are. + +In 2030-31, the year the taper starts, the model applies the election +separately to the weeks before the taper and the weeks under it, since the law +restricts entitlement day by day. A partner below the earnings limit has the +full rate in both, so the couple elect them for the whole year. Where both +partners earn over the limit, neither has an award before the taper, and the +couple elect the one with the larger award under it. Either way one partner +has the award for the year. The parts of the year reach the payment as shares +of the year: `carers_allowance_pre_overlap` is the amount from +`carers_allowance_pre_couple_limit` that the restriction keeps, and +overlapping benefits come off for the share of the year in which the restriction keeps it. The shares describe an amount only where the model's own formulas calculate it from the parts of the year and, for every member of the benefit unit, it is the amount the parts give: within half a penny, and an award, however -small, exactly where the parts give one, since the couple restriction picks -whoever has an award. An amount that an input supplies, or that a -reform neutralizes or calculates with a formula of its own, carries no dates, -even where it equals the model's: a value cannot show where it came from. So -does an amount that a reform changes while keeping the formula, for example -by adding `defined_for`. A `carers_allowance_pre_couple_limit` of that kind -gets the couple restriction for the whole year, and the whole year's +small, exactly where the parts give one, since the couple restriction chooses +only among partners with an award. An amount that an input supplies, or that +a reform neutralizes or calculates with a formula of its own, carries no +dates, even where it equals the model's: a value cannot show where it came +from. So does an amount that a reform changes while keeping the formula, for +example by adding `defined_for`. A `carers_allowance_pre_couple_limit` of that +kind gets the couple restriction for the whole year, and the whole year's overlapping benefits come off what the restriction keeps. A `carers_allowance_pre_overlap` of that kind comes after the couple restriction, so only the whole year's overlapping benefits come off it. Either way, the payment starts from that value, and where the `carers_allowance_pre_overlap` is of that kind, the payment doesn't read the -stage before it. A reform that changes only a -variable's label, say, keeps the parts of the year. The test of the formula -covers the whole variable, so a reform that wraps the formula to change one -person's amount moves every amount to the whole-year treatment. The model treats less than half a penny for the year, -which in practice is floating-point residue where an overlapping benefit -equals the award, as nil, and pays a penny. - -The convention does not maximise the couple's payment. It can select a partner -whose overlapping benefits reduce CA or CSP to zero, while suppressing a -payment that the other otherwise-eligible partner could receive if selected. +stage before it. A reform that changes only a variable's label, say, keeps +the parts of the year. The test of the formula covers the whole variable, so +a reform that wraps the formula to change one person's amount moves every +amount to the whole-year treatment. The model treats less than half a penny +for the year, which in practice is floating-point residue where an +overlapping benefit equals the award, as nil, and pays a penny. + +The election compares awards before overlapping benefits, so it doesn't +always maximise what the couple receive: it can choose a partner whose +overlapping benefits reduce CA or CSP to zero, where the other partner, if +chosen, would receive a payment. The existing same-person default remains: two qualifying reported awards, or awards that caring hours and take-up cannot explain, indicate different care diff --git a/docs/book/programs/gov/treasury/cost-of-living-payments.md b/docs/book/programs/gov/treasury/cost-of-living-payments.md index 901aae27a9..8bbccfe7bf 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -56,8 +56,8 @@ follow. The model pays every household in a qualifying band. Survey data hold only main homes, so the second homes and empty properties the scheme excludes do not arise. The model can't tell which households have a landlord liable for council tax (council tax in the rent), so it pays them, -which slightly overstates recipients. `council_tax_band` defaults to band D, so a household with no -band recorded qualifies. +which slightly overstates recipients. `council_tax_band` defaults to band D, +so a household whose survey record has no band qualifies. ## Cost-of-Living Payments (2022-23 and 2023-24) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 52d0c83b03..3baacfbaf7 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -12,22 +12,25 @@ earnings above the limit, floored at zero; - the taper never pays less than the cliff edge it replaces; - weekly earnings plus the allowance never fall as earnings rise: no cliff; -- Scotland (Carer Support Payment) and Northern Ireland are unchanged; -- in 2030-31 the allowance paid is the weekly overlap rule over the weeks it - is paid, for single carers and for couples caring for the same person, where - the couple restriction picks a partner separately before and under the taper; +- Scotland (Carer Support Payment) and Northern Ireland keep their rules; +- in 2030-31 the allowance paid is the weekly overlap rule over the weeks the + allowance pays for, for single carers and for couples caring for the same + person; +- a couple caring for the same person elect the larger award, so the taper + never leaves a couple with less than the cliff edge, and one partner has + the award for the year, in 2030-31 too, even where a reform masks the + other's fraction of a penny; - an award that an input supplies, or that a reform neutralizes, calculates with a formula of its own or masks with defined_for, before the couple limit or the overlap rule is what reaches the payment, with the whole year's treatment, even where it equals the model's; so 2030-31 pays such an award as 2031-32 does, while a reform that changes only a label keeps - the parts of the year; a mask that takes away a fraction of a penny counts, - since the couple restriction picks whoever has an award; + the parts of the year; - the payment reads the award before the couple limit only where the model calculates the award before overlap, so a supplied pre-overlap award needs nothing before it; - an overlapping benefit equal to the award leaves nothing paid or received, - a penny left over is paid, and anything paid is at least half a penny. + the model pays a penny left over, and any payment is at least half a penny. """ import numpy as np @@ -202,8 +205,8 @@ def test_earnings_plus_the_allowance_never_fall_as_earnings_rise(sims, drawn): assert (rise[tapers] >= -0.011).all() -# Overlapping benefits come off only in the weeks the allowance is paid. A -# carer over the limit in 2030-31 is paid from 6 October 2030 only, so only +# Overlapping benefits come off only in the weeks the allowance pays for. A +# carer over the limit in 2030-31 gets it from 6 October 2030 only, so only # that share of a weekly overlapping benefit reduces it. @@ -299,9 +302,11 @@ def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( assert ((paid == 0) | (paid >= 0.005)).all() -# A couple caring for the same person gets one award at a time. In 2030-31 -# the couple restriction picks a partner separately for the weeks before the -# taper and the weeks under it. +# A couple caring for the same person get one award at a time, and elect +# which of them has it: the model takes the larger award, the head's where +# the two are equal. In 2030-31 the couple restriction works on the weeks +# before the taper and the weeks under it separately, and a partner below +# the limit has the larger award in both. def couple(head_weekly_earnings, partner_weekly_earnings, weekly_state_pension): @@ -333,21 +338,35 @@ def member(age, weekly_earnings): } -def test_2030_31_couple_keeps_the_partner_award_before_the_taper(): - # The head is £46 over the limit and gets the tapered allowance from - # 6 October; the partner is under it and gets the full rate until then. +def test_2030_31_couple_elect_the_partner_under_the_limit(): + # The head is £46 over the limit, so from 6 October the taper gives them + # the rate less £46 a week; the partner is under it and has the full rate + # all year, the larger award, so the couple elect the partner for the + # whole year and only the partner counts as entitled. limit, rate = ca_parameters(START_YEAR) sim = Simulation(situation=couple(limit + 46, limit - 4, 40)) assert sim.calculate("partners_care_for_same_severely_disabled_person", START_YEAR)[ 0 ] - head, partner = sim.calculate("carers_allowance", START_YEAR).astype(float) - assert head == pytest.approx( - START_YEAR_SHARE * max(rate - 46 - 40, 0) * 52, abs=0.05 - ) - assert partner == pytest.approx( - (1 - START_YEAR_SHARE) * max(rate - 40, 0) * 52, abs=0.05 - ) + paid = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert paid == pytest.approx([0, max(rate - 40, 0) * 52], abs=0.05) + entitled = sim.calculate("is_entitled_to_carer_benefit", START_YEAR) + assert list(entitled) == [False, True] + + +@pytest.mark.parametrize("head_over, partner_over", [(46, 60), (60, 46)]) +def test_2030_31_couple_over_the_limit_elect_the_larger_tapered_award( + head_over, partner_over +): + # Both earn over the limit, so neither has an award before 6 October. + # Under the taper the one with less earnings over it has the larger + # award, and the couple elect them, head or partner. + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit + head_over, limit + partner_over, 40)) + paid = sim.calculate("carers_allowance", START_YEAR).astype(float) + larger = START_YEAR_SHARE * max(rate - min(head_over, partner_over) - 40, 0) * 52 + expected = [larger, 0] if head_over < partner_over else [0, larger] + assert paid == pytest.approx(expected, abs=0.05) def test_a_couple_under_the_limit_gets_one_award_all_year(): @@ -536,9 +555,14 @@ def apply(self): def couple_with_pension(year): - # The head is £46 a week over the earnings limit and the partner within - # it, each with £400 a year of State Pension. - return both_caring_couple(year, 400 / 52, head_weekly_excess=46) + # The head is £46 a week over the earnings limit and the partner £60 over + # it, so in 2030-31 neither has an award before the taper, and under it + # the head has the larger one, which the couple elect. Each has £400 a + # year of State Pension. + situation = both_caring_couple(year, 400 / 52, head_weekly_excess=46) + partner = situation["people"]["partner"] + partner["carers_allowance_weekly_earnings_above_limit"] = {year: 60} + return situation @pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) @@ -595,8 +619,8 @@ def test_a_reform_keeping_the_models_formulas_keeps_the_parts_of_the_year(year): expected ) if year == START_YEAR: - # The head is paid only under the taper, so less than the whole - # year's pension comes off. + # The head has the award only under the taper, so less than the + # whole year's pension comes off it. award = float(model.calculate("carers_allowance_pre_overlap", year)[0]) assert expected[0] > award - 400 + 100 @@ -633,33 +657,39 @@ def tiny_award_couple(year): return both_caring_couple(year, 400 / 52, head_weekly_excess=rate - 0.0001) -def test_a_fraction_of_a_penny_is_an_award_for_the_parts_of_the_year(): - # With no reform, the head's award picks the head for the weeks under the - # taper, and the partner keeps their award for the weeks before it. - sim = Simulation(situation=tiny_award_couple(START_YEAR)) - candidate = sim.calculate("carers_allowance_pre_couple_limit", START_YEAR) +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_fraction_of_a_penny_doesnt_take_the_award_from_the_partner(year): + # Under the taper the head keeps a fraction of a penny, but the partner's + # full rate is the larger award, so the couple elect the partner for the + # whole year. Picking the head for the weeks under the taper, as the + # model did before, paid the partner £2,301.24 too little in 2030-31 and + # nothing in 2031-32. + sim = Simulation(situation=tiny_award_couple(year)) + candidate = sim.calculate("carers_allowance_pre_couple_limit", year) candidate = candidate.astype(float) assert 0 < candidate[0] < 0.01 and candidate[1] > 4_000 - before_taper = 1 - START_YEAR_SHARE - pre_overlap = sim.calculate("carers_allowance_pre_overlap", START_YEAR) - assert pre_overlap.astype(float) == pytest.approx( - [candidate[0], candidate[1] * before_taper] - ) - paid = sim.calculate("carers_allowance", START_YEAR).astype(float) - assert paid == pytest.approx([0, pre_overlap[1] - before_taper * 400], abs=0.01) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year) + assert pre_overlap.astype(float) == pytest.approx([0, candidate[1]]) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([0, candidate[1] - 400], abs=0.01) @pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) @pytest.mark.parametrize( "masked", ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"] ) -def test_a_reform_masking_a_fraction_of_a_penny_gets_the_whole_year(year, masked): +def test_a_reform_masking_a_fraction_of_a_penny_leaves_the_partners_award(year, masked): # The reform limits a stage to women, so it takes away the male head's # fraction of a penny. That is within half a penny of the parts of the - # year, but the couple restriction picks whoever has an award, so the - # masked stage carries no dates and the stages after it work on the whole - # year: the partner keeps any award they have before overlap, less the - # whole year's £400 of State Pension. + # year, but the couple restriction chooses only among partners with an + # award, so a masked pre-couple-limit stage carries no dates. Either way + # the couple elect the partner, who keeps their award less the year's + # £400 of State Pension. + partner = float( + Simulation(situation=tiny_award_couple(year)).calculate( + "carers_allowance_pre_couple_limit", year + )[1] + ) reform = updated([masked], {"defined_for": "is_female"}) situation = tiny_award_couple(year) situation["people"]["head"]["is_female"] = {year: False} @@ -668,13 +698,10 @@ def test_a_reform_masking_a_fraction_of_a_penny_gets_the_whole_year(year, masked award = sim.calculate("carers_allowance_pre_couple_limit", year).astype(float) pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) if masked == "carers_allowance_pre_couple_limit": - assert award[0] == 0 and award[1] > 4_000 - assert pre_overlap == pytest.approx(award) - assert pre_overlap[0] == 0 - if year == START_YEAR: - assert pre_overlap[1] > 2_000 + assert award == pytest.approx([0, partner]) + assert pre_overlap == pytest.approx([0, partner]) paid = sim.calculate("carers_allowance", year).astype(float) - assert paid == pytest.approx(np.maximum(pre_overlap - 400, 0), abs=0.01) + assert paid == pytest.approx([0, partner - 400], abs=0.01) @pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) @@ -705,8 +732,8 @@ def test_a_supplied_pre_overlap_award_needs_no_award_before_the_couple_limit(yea # An award a reform sets before the couple limit carries no dates, so 2030-31 -# restricts it and takes overlapping benefits off it as 2031-32 does, picking -# the head where the head has an award and the partner otherwise. +# restricts it and takes overlapping benefits off it as 2031-32 does, keeping +# the larger award, the head's where the two are equal. reform_award = st.one_of( st.just(0.0), st.floats(min_value=0.01, max_value=6_000).map(lambda x: round(x, 2)) @@ -793,7 +820,7 @@ def test_2030_31_pays_an_award_a_reform_sets_as_2031_32_does(reform_award_sim, d "partners_care_for_same_severely_disabled_person", year ).all() award = award.reshape(-1, 2) - head = award[:, 0] > 0 + head = (award[:, 0] > 0) & (award[:, 0] >= award[:, 1]) kept = np.column_stack([head, ~head]) * award expected = np.maximum(0, kept - 52 * pension.reshape(-1, 2)) expected = np.where(expected < 0.005, 0, expected) @@ -873,10 +900,13 @@ def test_an_overlapping_benefit_equal_to_the_award_leaves_nothing_received( assert not sim.calculate("receives_carers_allowance", year).any() +# Whole pence, so the two partners' awards are equal or at least a penny +# apart, wherever float32 rounds them. +pence = weekly.map(lambda earnings: round(earnings, 2)) couple_cases = st.lists( st.tuples( - weekly, - weekly, + pence, + pence, st.floats(min_value=0, max_value=150), st.floats(min_value=0, max_value=150), ), @@ -885,19 +915,22 @@ def test_an_overlapping_benefit_equal_to_the_award_leaves_nothing_received( ) -def couples_situation(): +def couples_situation(years=(START_YEAR,)): people, benunits, households = {}, {}, {} for i in range(CASES): members = [f"head_{i}", f"partner_{i}"] - people[members[0]] = {"age": {START_YEAR: 71}, "care_hours": {START_YEAR: 40}} - people[members[1]] = {"age": {START_YEAR: 70}, "care_hours": {START_YEAR: 40}} + for name, age in zip(members, (71, 70)): + people[name] = { + "age": {year: age + year - START_YEAR for year in years}, + "care_hours": {year: 40 for year in years}, + } benunits[f"benunit_{i}"] = { "members": members, - "would_claim_uc": {START_YEAR: False}, + "would_claim_uc": {year: False for year in years}, } households[f"household_{i}"] = { "members": members, - "region": {START_YEAR: REGIONS[i % len(REGIONS)]}, + "region": {year: REGIONS[i % len(REGIONS)] for year in years}, } return {"people": people, "benunits": benunits, "households": households} @@ -943,11 +976,64 @@ def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( ) else: weekly = np.where(above > 0, 0, rate) - # The head when eligible, otherwise the partner. - head_paid = weekly[:, 0] > 0 + # The larger award, the head's where the two are equal. + head_paid = (weekly[:, 0] > 0) & (weekly[:, 0] >= weekly[:, 1]) selected = np.column_stack([head_paid, ~head_paid]) expected += share * 52 * selected * np.maximum(0, weekly - pension) # Scotland pays Carer Support Payment instead. england_wales_ni = regions != "SCOTLAND" assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() assert ((paid == 0) | (paid >= 0.005)).all() + + +# The couple elect the larger award, so an award the taper gives one partner +# never takes the award from the other's larger one: the couple get at least +# what the cliff edge gave them, one partner has the award for the year, and +# in 2031-32 that award is the larger of the two before the couple limit. + + +@pytest.fixture(scope="module") +def couple_years_sims(): + situation = couples_situation((START_YEAR, FULL_YEAR)) + return Simulation(situation=situation), Simulation( + situation=situation, reform=WITHOUT_TAPER + ) + + +@SETTINGS +@given(couple_cases) +def test_the_taper_never_leaves_a_couple_with_less_than_the_cliff_edge( + couple_years_sims, drawn +): + head_earnings, partner_earnings, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + for sim in couple_years_sims: + for year in (START_YEAR, FULL_YEAR): + sim.set_input("carers_allowance_earnings", year, 52 * earnings) + sim.set_input("state_pension", year, 52 * pension) + sim.reset_calculations() + taper, cliff = couple_years_sims + for year in (START_YEAR, FULL_YEAR): + assert taper.calculate( + "partners_care_for_same_severely_disabled_person", year + ).all() + paid, paid_at_cliff = ( + sim.calculate("carers_allowance", year).astype(float).reshape(-1, 2) + for sim in (taper, cliff) + ) + assert (paid.sum(axis=1) >= paid_at_cliff.sum(axis=1) - 0.01).all() + pre_overlap = ( + taper.calculate("carers_allowance_pre_overlap", year) + .astype(float) + .reshape(-1, 2) + ) + assert ((pre_overlap > 0).sum(axis=1) <= 1).all() + candidate = ( + taper.calculate("carers_allowance_pre_couple_limit", FULL_YEAR) + .astype(float) + .reshape(-1, 2) + ) + assert pre_overlap.sum(axis=1) == pytest.approx(candidate.max(axis=1)) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index cf966e9929..06ed4411b8 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -3,7 +3,9 @@ import numpy as np # The tolerance for reading a stored award: values within half a penny of -# each other are the same, and an award within half a penny of zero is none. +# each other are the same, and an award within half a penny of zero is none, +# except before the Carer's Allowance couple restriction, which counts any +# positive award (utils/carers_allowance_taper.py). HALF_PENNY = 0.005 # For each award award_of_members reads, the person-level variable saying the diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index 7d85f5fe7f..0e9efa48f6 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -107,8 +107,8 @@ def matches_parts_of_year(person, amount, parts_amount): """Whether, for every member of the benefit unit, amount is parts_amount, what the parts of the year give: within half a penny, since annual amounts are float32, good to about a thousandth of a pound, and positive - exactly where parts_amount is, since the couple restriction picks a - partner by whether their award is positive.""" + exactly where parts_amount is, since the couple restriction chooses only + among partners whose award is positive.""" same = (np.abs(amount - parts_amount) < HALF_PENNY) & ( (amount > 0) == (parts_amount > 0) ) diff --git a/policyengine_uk/utils/couple_carer_award.py b/policyengine_uk/utils/couple_carer_award.py index e5d4ca950d..5cb4d2057e 100644 --- a/policyengine_uk/utils/couple_carer_award.py +++ b/policyengine_uk/utils/couple_carer_award.py @@ -42,14 +42,19 @@ def unlimited_carer_benefit_entitlement(person, period): def limit_carer_benefit_to_one_partner(person, period, candidate): """One candidate award for a same-target couple, before overlap reductions. - Election/discretion is not observed. Prefer an eligible reported recipient, - then an eligible benefit-unit head, then the eldest eligible partner (stable - member order breaks age ties). Other members and units with other than two + The couple jointly elect which of them has the entitlement (SSCBA 1992 + s.70(7ZA)(a)). The data don't record the election, so the model takes the + one that gives the larger award, as it does for other elections it models. + Awards differ only under the Carer's Allowance earnings taper; where they + are equal, prefer an eligible reported recipient, then an eligible + benefit-unit head, then the eldest eligible partner (stable member order + breaks age ties). Other members and units with other than two claimant/partner flags are outside this couple-only restriction. """ benunit = person.benunit partner = person("is_claimant_or_partner", period) - eligible = partner & (candidate > 0) + largest = benunit.max(np.where(partner, candidate, 0)) + eligible = partner & (candidate > 0) & (candidate >= largest) reported = eligible & (person("carers_allowance_reported", period) > 0) preferred = np.where(benunit.any(reported), reported, eligible) head = preferred & person("is_benunit_head", period) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index 1691f61510..a3dae8a1a6 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -21,12 +21,12 @@ class carers_allowance(Variable): label = "Carer's Allowance" documentation = ( "Carer's Allowance payable after the overlapping-benefit adjustment: " - "the allowance is reduced by any other personal benefit that overlaps " - "with it, such as State Pension, and only the balance is paid. The " - "comparison is made on annual amounts, which equals the weekly rule " - "when the overlapping benefit pays a constant rate all year. Where " - "the overlapping benefit exceeds the carer rate in some weeks and " - "falls short of it or stops in others, for example when it starts " + "any other personal benefit that overlaps with it, such as State " + "Pension, reduces the allowance, and the model pays only the balance. " + "The model compares annual amounts, which gives the weekly rule's " + "result when the overlapping benefit pays a constant rate all year. " + "Where the overlapping benefit exceeds the carer rate in some weeks " + "and falls short of it or stops in others, for example when it starts " "partway through the year, the annual comparison pays less than the " "weekly rule would; annual inputs carry no dates to do better. In the " "year the Budget 2026 earnings taper starts, overlapping benefits " diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index a659a2f4ac..6817ab804e 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -16,19 +16,19 @@ class carers_allowance_pre_overlap(Variable): entity = Person label = "Carer's Allowance before the overlapping-benefit adjustment" documentation = ( - "The same-person couple restriction is applied before overlapping " - "benefits, using the reported-award/head modelling convention described " - "in the carer benefit assumptions. " + "The model applies the same-person couple restriction before " + "overlapping benefits, giving the award to the partner the couple " + "would elect, as the carer benefit assumptions describe. " "The Carer's Allowance a person is entitled to before the Social " "Security (Overlapping Benefits) Regulations 1979 reduce it by another " - "personal benefit. A person whose allowance is reduced to nil keeps " - "this underlying entitlement. is_entitled_to_carer_benefit combines " + "personal benefit. A person whose allowance overlapping benefits " + "reduce to nil keeps this underlying entitlement. is_entitled_to_carer_benefit combines " "the pre-overlap amounts and any benefit in payment for rules that " "test entitlement. carers_allowance is the amount payable. In the " "year the Budget 2026 earnings taper starts, the couple restriction " - "picks a partner separately for the weeks before the taper and the " - "weeks under it, so a partner below the earnings limit keeps the " - "award for the weeks before the other becomes eligible. A " + "works on the weeks before the taper and the weeks under it " + "separately; a partner below the earnings limit has the larger award " + "in both, so one partner has the award for the year. A " "carers_allowance_pre_couple_limit that an input supplies, or that a " "reform neutralizes, calculates with a formula of its own or " "otherwise changes (with defined_for, say), carries no dates, so the " From ab9d57b3ec23a2de6c14f1b9f81209635bdc6e9f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 02:21:48 -0400 Subject: [PATCH 25/29] Answer review round 9, and use HM Treasury's July 2027 gas rate Round 9 approved the couple election with four P3s: - "one partner has the award for the year" holds for the awards the model calculates (earnings and the limit in whole pence put a tapered award at least a penny below the full rate); a supplied award within a few millionths of a pound can tie in float32, and the docs now say so; - "nothing changes before 2030-31" holds for the model's own awards; a supplied candidate that differs decides the election in any year; - under the election no payment shows the sign check in matches_parts_of_year, so a direct test now covers it (it fails without the check; the six payment cases pass either way); - passives in the test module and the assumptions page. From the rulespec-uk parity notes: natural gas fuel duty from 1 July 2027 is HM Treasury's published 25.82p a kilogram (OOTLAR Annex A), a hundredth of a penny below the RPI uprating's 25.83p. calculate_fuel_duty_rates.py now writes the published rates and stops if one is more than a hundredth of a penny from the uprating; the April steps follow (26.94p, 27.76p, 28.59p, 29.24p). The Northern Ireland electricity VAT and Household Energy Payment parameters now cite paragraphs 3.4 and 3.5 of the Budget. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- changelog.d/ab26-fuel-duty.changed.md | 2 +- .../carer-support-payment-earnings.md | 33 +++++++----- docs/engineering/excise-duties.md | 7 ++- .../fuel_duty/calculate_fuel_duty_rates.py | 52 ++++++++++++++++--- .../parameters/gov/hmrc/fuel_duty/lpg.yaml | 4 +- .../gov/hmrc/fuel_duty/natural_gas.yaml | 31 ++++++----- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 4 +- .../northern_ireland.yaml | 4 +- .../household_energy_payment/amount.yaml | 4 +- .../household_energy_payment/countries.yaml | 4 +- .../gov/hmrc/fuel_duty/road_fuel_gases.yaml | 17 +++--- .../test_carers_allowance_earnings_taper.py | 21 +++++++- .../tests/test_fuel_duty_schedule.py | 14 +++-- policyengine_uk/utils/couple_carer_award.py | 5 +- .../gov/dwp/carers_allowance_pre_overlap.py | 3 +- 16 files changed, 144 insertions(+), 63 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 2e13808ac0..3857562041 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and, of the awards the model calculates, one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. diff --git a/changelog.d/ab26-fuel-duty.changed.md b/changelog.d/ab26-fuel-duty.changed.md index 093c39325c..b7065867c7 100644 --- a/changelog.d/ab26-fuel-duty.changed.md +++ b/changelog.d/ab26-fuel-duty.changed.md @@ -1 +1 @@ -Keep the 5p fuel duty cut, and the proportionate cut for LPG and natural gas, to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI growth to 2027 Q1), and LPG and natural gas return to their full rates with the same uprating; all three then rise by RPI each 1 April from 2028. +Keep the 5p fuel duty cut, and the proportionate cut for LPG and natural gas, to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI growth to 2027 Q1), and LPG and natural gas return to their full rates with the same uprating, at the rates HM Treasury publishes (natural gas 25.82p a kilogram); all three then rise by RPI each 1 April from 2028. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index bf3679bf1b..baf0f154cb 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -92,13 +92,15 @@ The couple jointly elect which of them has the entitlement; the Secretary of State decides only where they don't. The data don't record the election, so the model takes the one that gives the larger award: of the otherwise eligible partners, the one whose candidate award is larger has the entitlement. The -awards differ only under the Budget 2026 Carer's Allowance earnings taper; -everywhere else each eligible partner has the same rate. Where the awards are -equal, the model prefers a reported recipient, then an eligible benefit-unit -head, then the eldest eligible partner, and member order breaks an age tie. -That order is a modelling convention, not statutory priority. An ineligible -reported recipient or head cannot displace an eligible partner. The -caring-hours, earnings, age and take-up conditions stay as they are. +model's own awards differ only under the Budget 2026 Carer's Allowance +earnings taper; everywhere else each eligible partner has the same rate. An +award that an input supplies can differ in any year, and the election then +follows it. Where the awards are equal, the model prefers a reported +recipient, then an eligible benefit-unit head, then the eldest eligible +partner, and member order breaks an age tie. That order is a modelling +convention, not statutory priority. An ineligible reported recipient or head +cannot displace an eligible partner. The caring-hours, earnings, age and +take-up conditions stay as they are. In 2030-31, the year the taper starts, the model applies the election separately to the weeks before the taper and the weeks under it, since the law @@ -106,8 +108,13 @@ restricts entitlement day by day. A partner below the earnings limit has the full rate in both, so the couple elect them for the whole year. Where both partners earn over the limit, neither has an award before the taper, and the couple elect the one with the larger award under it. Either way one partner -has the award for the year. The parts of the year reach the payment as shares -of the year: `carers_allowance_pre_overlap` is the amount from +has the award for the year: the model reads earnings in whole pence against a +limit in whole pence, so its own tapered award falls at least a penny short of +the full rate. An award that an input supplies within a few millionths of a +pound of the full rate can tie with it once the model stores both as float32, +and then the tie order can pick a different partner for each part of the year. +The parts of the year reach the payment as shares of the year: +`carers_allowance_pre_overlap` is the amount from `carers_allowance_pre_couple_limit` that the restriction keeps, and overlapping benefits come off for the share of the year in which the restriction keeps it. @@ -134,10 +141,10 @@ amount to the whole-year treatment. The model treats less than half a penny for the year, which in practice is floating-point residue where an overlapping benefit equals the award, as nil, and pays a penny. -The election compares awards before overlapping benefits, so it doesn't -always maximise what the couple receive: it can choose a partner whose -overlapping benefits reduce CA or CSP to zero, where the other partner, if -chosen, would receive a payment. +The election compares awards before overlapping benefits, so it doesn't always +maximise what the couple receive: it can choose a partner whose overlapping +benefits reduce CA or CSP to zero, where choosing the other partner would pay +something. The existing same-person default remains: two qualifying reported awards, or awards that caring hours and take-up cannot explain, indicate different care diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index c32785f76c..7c4f60ec3e 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -158,8 +158,11 @@ Budget 2026's costing says that on 1 July 2027 "fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI)", and applies the cut to other fuels in proportion. So on 1 July 2027 the gas rates return to their full rates before the 2022 cut (LPG £0.3161/kg, natural gas -£0.2470/kg) uprated by the same RPI growth as petrol and diesel: £0.3305/kg -and £0.2583/kg. Each 1 April from 2028 they rise by the previous calendar +£0.2470/kg) uprated by the same RPI growth as petrol and diesel. HM +Treasury publishes the rates from 1 July 2027 in Annex A of Budget 2026's +Overview of Tax Legislation and Rates: £0.3305/kg and £0.2582/kg (the +uprating gives £0.2583/kg for natural gas, and the model uses HM Treasury's +rate). Each 1 April from 2028 they rise by the previous calendar year's RPI growth. `calculate_fuel_duty_rates.py` writes these steps to 1 April 2031, as it does for petrol and diesel, and the parameters' uprating metadata continues them. That is policy, not law. diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index d26493ba40..42bc8ccb1c 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -10,7 +10,12 @@ 2026 EFO): the 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI growth in the year to 2027 Q1 (CUT_END_RPI_GROWTH). LPG and natural gas take the proportionate cut to the same date, so their full rates get the -same uprating then. From 1 April 2028 the script applies each previous +same uprating then. HM Treasury publishes the rates from 1 July 2027 in +Budget 2026's Overview of Tax Legislation and Rates, Annex A +(PUBLISHED_RATES), and the script writes those: each is within a hundredth +of a penny of the RPI uprating, and the script stops if one isn't. Natural +gas is the one that differs: 24.7p x 1.045565 rounds half up to 25.83p, and +HM Treasury publishes 25.82p. From 1 April 2028 the script applies each previous calendar year's RPI growth, using economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of a penny and writes the results between the BEGIN and END markers in each file. After a new EFO, set CUT_END_RATE @@ -46,6 +51,14 @@ # 2027 Q1 on a year earlier (4.5565%, economy Table 1.7). CUT_END_RATE = Decimal("0.6059") CUT_END_RPI_GROWTH = Decimal("0.045565") +# HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A, +# fuel duty table: the rates from 1 July 2027. +ANNEX_A = "https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances" +PUBLISHED_RATES = { + "petrol_and_diesel.yaml": Decimal("0.6059"), + "lpg.yaml": Decimal("0.3305"), + "natural_gas.yaml": Decimal("0.2582"), +} EFO = "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/" FIRST_APRIL_YEAR = 2028 LAST_UPRATING_YEAR = 2031 @@ -81,11 +94,17 @@ def uprate(rate: Decimal, growth: Decimal) -> Decimal: def forecast_steps(fuel: str) -> list: """(date, rate, previous rate, RPI growth) for each forecast step. The - 1 July 2027 step applies the OBR's RPI growth to 2027 Q1, so it has no + 1 July 2027 step is HM Treasury's published rate, which the OBR's RPI + growth to 2027 Q1 gives to within a hundredth of a penny, so it has no calendar-year growth of its own.""" growth = rpi_growth_by_year() base = base_rate(fuel) - rate = uprate(base, CUT_END_RPI_GROWTH) + rate = PUBLISHED_RATES[fuel] + if abs(rate - uprate(base, CUT_END_RPI_GROWTH)) > PENNY_HUNDREDTH: + raise ValueError( + f"{fuel}: HM Treasury's {rate} on {CUT_END_DATE} is more than a " + f"hundredth of a penny from {base} x (1 + {CUT_END_RPI_GROWTH})" + ) steps = [(CUT_END_DATE, rate, base, None)] for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1): previous = rate @@ -101,21 +120,32 @@ def render(fuel: str, steps: list) -> str: for date, rate, previous, growth in steps: if growth is None: pct = f"{CUT_END_RPI_GROWTH * 100:.2f}%" + uprated = uprate(previous, CUT_END_RPI_GROWTH) + how = ( + "the same" + if uprated == rate + else f"{uprated}, rounded half up; HM Treasury's rate is a " + "hundredth of a penny lower" + ) if fuel == "petrol_and_diesel.yaml": note = ( - f"OBR forecast. {previous} uprated by RPI growth in the " - f"year to 2027 Q1 ({pct})." + f"HM Treasury's rate from 1 July 2027. The OBR's forecast, " + f"{previous} uprated by RPI growth in the year to 2027 Q1 " + f"({pct}), gives {how}." ) title = "OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)" else: note = ( - f"Forecast. {previous} uprated by RPI growth in the year " - f"to 2027 Q1 ({pct}), as the OBR uprates petrol and diesel." + f"HM Treasury's rate from 1 July 2027. {previous} uprated " + f"by RPI growth in the year to 2027 Q1 ({pct}), as the OBR " + f"uprates petrol and diesel, gives {how}." ) title = ( "OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027)" ) source = [ + " - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027)", + f" href: {ANNEX_A}", f" - title: {title}", f" href: {EFO}", ] @@ -169,7 +199,13 @@ def main(): ) print(f"{fuel}: {FUELS[fuel]} {base_rate(fuel)} (full rate)") for date, rate, previous, growth in steps: - how = f"{previous} x (1 + {growth or CUT_END_RPI_GROWTH})" + if growth is None: + uprated = uprate(previous, CUT_END_RPI_GROWTH) + how = ( + f"HM Treasury; {previous} x (1 + {CUT_END_RPI_GROWTH}) = {uprated}" + ) + else: + how = f"{previous} x (1 + {growth})" print(f" {date}: {rate} ({how})") if args.update: update(fuel, steps) diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index 8bfd14f42e..7feaf0be7d 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -12,12 +12,14 @@ values: 2027-07-01: value: 0.3305 metadata: - note: Forecast. 0.3161 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel. + note: HM Treasury's rate from 1 July 2027. 0.3161 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel, gives the same. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2028-04-01: diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index 8667516aa5..9998a9fc4d 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -4,26 +4,29 @@ values: 2022-03-23: 0.2257 # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; # policy costings page 13). The full rate then returns uprated by RPI - # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel, - # and rises by the previous calendar year's RPI growth each 1 April from - # 2028. That is policy, not law. After the last entry, the uprating - # metadata below continues the same April cadence. + # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel + # (HM Treasury publishes 25.82p, a hundredth of a penny below the uprated + # 25.83p), and rises by the previous calendar year's RPI growth each + # 1 April from 2028. That is policy, not law. After the last entry, the + # uprating metadata below continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py 2027-07-01: - value: 0.2583 + value: 0.2582 metadata: - note: Forecast. 0.247 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel. + note: HM Treasury's rate from 1 July 2027. 0.247 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel, gives 0.2583, rounded half up; HM Treasury's rate is a hundredth of a penny lower. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2028-04-01: - value: 0.2695 + value: 0.2694 metadata: - note: Forecast. 0.2583 uprated by 2027 RPI growth of 4.3%. + note: Forecast. 0.2582 uprated by 2027 RPI growth of 4.3%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -32,9 +35,9 @@ values: - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2029-04-01: - value: 0.2777 + value: 0.2776 metadata: - note: Forecast. 0.2695 uprated by 2028 RPI growth of 3.0%. + note: Forecast. 0.2694 uprated by 2028 RPI growth of 3.0%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -43,9 +46,9 @@ values: - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2030-04-01: - value: 0.2860 + value: 0.2859 metadata: - note: Forecast. 0.2777 uprated by 2029 RPI growth of 3.0%. + note: Forecast. 0.2776 uprated by 2029 RPI growth of 3.0%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -54,9 +57,9 @@ values: - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2031-04-01: - value: 0.2925 + value: 0.2924 metadata: - note: Forecast. 0.2860 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 0.2859 uprated by 2030 RPI growth of 2.3%. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index 3283fb028a..cdbaf066f1 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -106,12 +106,14 @@ values: 2027-07-01: value: 0.6059 metadata: - note: OBR forecast. 0.5795 uprated by RPI growth in the year to 2027 Q1 (4.56%). + note: HM Treasury's rate from 1 July 2027. The OBR's forecast, 0.5795 uprated by RPI growth in the year to 2027 Q1 (4.56%), gives the same. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances - title: OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2028-04-01: diff --git a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml index 3c864f6a29..364d3e1ea8 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml @@ -10,8 +10,8 @@ values: reference: - title: Budget 2026, paragraph 6.210 ("expected to apply UK-wide from April 2027") href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 - - title: Budget 2026, section 4.3 ("Extended VAT relief is expected to apply in Northern Ireland from April 2027") - href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html + - title: Budget 2026, paragraph 3.4 ("Extended VAT relief is expected to apply in Northern Ireland from April 2027") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 2027-07-01: value: 0.05 metadata: diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml index 3bf272e6e5..6f90a5993a 100644 --- a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml @@ -12,8 +12,8 @@ values: reference: - title: Budget 2026, paragraph 6.24 (Household Energy Payment) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117 - - title: Budget 2026, section 4.3 (£100 for households in bands A-D in England from January 2027) - href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html + - title: Budget 2026, paragraph 3.5 (£100 for households in bands A-D in England from January 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 - title: Budget 2026, Table 6.1, item 1 href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx 2027-04-06: 0 diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml index b381e4c14a..2c1b91a3a6 100644 --- a/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml @@ -8,5 +8,5 @@ metadata: period: year label: Household Energy Payment countries reference: - - title: Budget 2026, section 4.3 (bands A-D in England; devolved governments funded through the Barnett formula) - href: https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html + - title: Budget 2026, paragraph 3.5 (bands A-D in England; the devolved governments get their share through the Barnett formula) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml index 90c2106618..2b65d23710 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml @@ -1,10 +1,11 @@ # Gas quantities are kilograms. Budget 2026 keeps the 5p-equivalent cut to # 1 July 2027 (paragraph 6.211; policy costings page 13), so FY2026 is all at # the cut rates. On 1 July 2027 the full rates return uprated by RPI growth in -# the year to 2027 Q1 (4.5565%): LPG 33.05p, natural gas 25.83p a kilogram. -# Each 1 April from 2028 applies the previous calendar year's RPI growth, on -# the OBR's October 2026 forecast: LPG 34.48p and natural gas 26.95p in 2028, -# then 35.52p and 27.77p in 2029. +# the year to 2027 Q1 (4.5565%): HM Treasury publishes LPG at 33.05p and +# natural gas at 25.82p a kilogram (the uprating gives 25.83p). Each 1 April +# from 2028 applies the previous calendar year's RPI growth, on the OBR's +# October 2026 forecast: LPG 34.48p and natural gas 26.94p in 2028, then +# 35.52p and 27.76p in 2029. # FY2027 (366 days) weights are 86, 275 and 5 days. # Rural relief only reduces the petrol/diesel component. - name: No road fuel gas consumption @@ -59,8 +60,8 @@ diesel_litres: 0 output: # 1000 * (0.2888 * 86 + 0.3305 * 275 + 0.3448 * 5) / 366 - # + 1000 * (0.2257 * 86 + 0.2583 * 275 + 0.2695 * 5) / 366 - fuel_duty: 571.6898907103825 + # + 1000 * (0.2257 * 86 + 0.2582 * 275 + 0.2694 * 5) / 366 + fuel_duty: 571.6133879781421 - name: Fiscal 2028 gas rates rise by 2027 RPI on 1 April 2028 period: 2028 absolute_error_margin: 0.01 @@ -71,5 +72,5 @@ diesel_litres: 0 output: # 1000 * (0.3448 * 360 + 0.3552 * 5) / 365 - # + 1000 * (0.2695 * 360 + 0.2777 * 5) / 365 - fuel_duty: 614.554794520548 + # + 1000 * (0.2694 * 360 + 0.2776 * 5) / 365 + fuel_duty: 614.4547945205479 diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 3baacfbaf7..a037b7a8c9 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -13,7 +13,7 @@ - the taper never pays less than the cliff edge it replaces; - weekly earnings plus the allowance never fall as earnings rise: no cliff; - Scotland (Carer Support Payment) and Northern Ireland keep their rules; -- in 2030-31 the allowance paid is the weekly overlap rule over the weeks the +- in 2030-31 the payment follows the weekly overlap rule over the weeks the allowance pays for, for single carers and for couples caring for the same person; - a couple caring for the same person elect the larger award, so the taper @@ -39,6 +39,7 @@ from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.carers_allowance_taper import matches_parts_of_year pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -704,6 +705,24 @@ def test_a_reform_masking_a_fraction_of_a_penny_leaves_the_partners_award(year, assert paid == pytest.approx([0, partner - 400], abs=0.01) +def test_a_mask_on_a_fraction_of_a_penny_doesnt_match_the_parts_of_the_year(): + # The couple restriction chooses only among partners with an award, so an + # amount matches what the parts of the year give only where it is + # positive exactly where they are: within half a penny isn't enough. The + # election gives a head's fraction of a penny nothing beside the + # partner's full rate, so no payment shows this check; this tests it + # directly. + sim = Simulation(situation=tiny_award_couple(START_YEAR)) + person = sim.populations["person"] + parts = np.array([0.0026, 2_000], dtype=np.float32) + assert matches_parts_of_year(person, parts, parts).all() + nearby = np.array([0.0066, 2_000.004], dtype=np.float32) + assert matches_parts_of_year(person, nearby, parts).all() + masked = np.array([0, 2_000], dtype=np.float32) + assert not matches_parts_of_year(person, masked, parts).any() + assert not matches_parts_of_year(person, parts, masked).any() + + @pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) def test_a_supplied_pre_overlap_award_needs_no_award_before_the_couple_limit(year): # This reform derives the award before the couple limit from the payment, diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index f704752de4..4334a1198b 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -125,13 +125,17 @@ def test_forecast_steps_use_current_rpi_forecast(): @pytest.mark.parametrize( - "fuel, full_rate", [("lpg", "0.3161"), ("natural_gas", "0.2470")] + "fuel, full_rate, published", + [("lpg", "0.3161", "0.3305"), ("natural_gas", "0.2470", "0.2582")], ) -def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate): +def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate, published): """LPG and natural gas take the proportionate cut to 1 July 2027 (Budget 2026, paragraph 6.211; costings page 13). Their full rates then get the OBR's 1 July uprating for petrol and diesel, 57.95p to 60.59p, and RPI - each 1 April from 2028.""" + each 1 April from 2028. HM Treasury publishes the 1 July 2027 rates + (Overview of Tax Legislation and Rates, Annex A), and the schedule uses + them: LPG's is the uprated rate, and natural gas's 25.82p is a hundredth + of a penny below the uprated 25.83p.""" parameters = raw_parameters() rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi schedule = getattr(parameters.gov.hmrc.fuel_duty, fuel) @@ -139,9 +143,11 @@ def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate): assert (Decimal("0.5795") * (1 + july_growth)).quantize( Decimal("0.0001"), ROUND_HALF_UP ) == Decimal(str(CUT_END_RATE)) - rate = (Decimal(full_rate) * (1 + july_growth)).quantize( + uprated = (Decimal(full_rate) * (1 + july_growth)).quantize( Decimal("0.0001"), ROUND_HALF_UP ) + rate = Decimal(published) + assert abs(rate - uprated) <= Decimal("0.0001") assert schedule("2027-06-30") < float(full_rate) assert schedule(CUT_END_DATE) == pytest.approx(float(rate), abs=1e-12) for date in FORECAST_DATES: diff --git a/policyengine_uk/utils/couple_carer_award.py b/policyengine_uk/utils/couple_carer_award.py index 5cb4d2057e..b1ac21481a 100644 --- a/policyengine_uk/utils/couple_carer_award.py +++ b/policyengine_uk/utils/couple_carer_award.py @@ -45,8 +45,9 @@ def limit_carer_benefit_to_one_partner(person, period, candidate): The couple jointly elect which of them has the entitlement (SSCBA 1992 s.70(7ZA)(a)). The data don't record the election, so the model takes the one that gives the larger award, as it does for other elections it models. - Awards differ only under the Carer's Allowance earnings taper; where they - are equal, prefer an eligible reported recipient, then an eligible + The model's own awards differ only under the Carer's Allowance earnings + taper, though supplied awards can differ in any year; where they are + equal, prefer an eligible reported recipient, then an eligible benefit-unit head, then the eldest eligible partner (stable member order breaks age ties). Other members and units with other than two claimant/partner flags are outside this couple-only restriction. diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 6817ab804e..8fdf95de42 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -28,7 +28,8 @@ class carers_allowance_pre_overlap(Variable): "year the Budget 2026 earnings taper starts, the couple restriction " "works on the weeks before the taper and the weeks under it " "separately; a partner below the earnings limit has the larger award " - "in both, so one partner has the award for the year. A " + "in both, so, of the awards the model calculates, one partner has the " + "award for the year. A " "carers_allowance_pre_couple_limit that an input supplies, or that a " "reform neutralizes, calculates with a formula of its own or " "otherwise changes (with defined_for, say), carries no dates, so the " From ae88f0cd24cbfe0830363289c3d22150dd4df370 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 02:46:43 -0400 Subject: [PATCH 26/29] Answer review round 10: name the earnings reader, fix the fuel script's checks Round 10 approved with three P3s: - one partner has the award for the year where the model reads earnings itself, in whole pence against a whole-pence limit. An input that supplies the earnings above the limit bypasses that reader and can tie the two awards in float32 (round 10: both stages still count as the model's), while a supplied award gets the whole-year restriction and one recipient. The assumptions page, the pre-overlap documentation and the changelog now name that boundary; - calculate_fuel_duty_rates.py's refresh instructions now include PUBLISHED_RATES, and main() checks the OBR's uprating and HM Treasury's published petrol and diesel rate separately, so each error names the constant at fault; - passives in the generator and its notes, the excise docs, the fuel test and the carer test docstring. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 8 ++- docs/engineering/excise-duties.md | 10 ++-- .../fuel_duty/calculate_fuel_duty_rates.py | 58 +++++++++++-------- .../parameters/gov/hmrc/fuel_duty/lpg.yaml | 10 ++-- .../gov/hmrc/fuel_duty/natural_gas.yaml | 14 ++--- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 10 ++-- .../test_carers_allowance_earnings_taper.py | 2 +- .../tests/test_fuel_duty_schedule.py | 4 +- .../gov/dwp/carers_allowance_pre_overlap.py | 4 +- 10 files changed, 66 insertions(+), 56 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 3857562041..280fa7c1dd 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and, of the awards the model calculates, one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and, where the model reads earnings itself, one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index baf0f154cb..57bc21d25a 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -110,9 +110,11 @@ partners earn over the limit, neither has an award before the taper, and the couple elect the one with the larger award under it. Either way one partner has the award for the year: the model reads earnings in whole pence against a limit in whole pence, so its own tapered award falls at least a penny short of -the full rate. An award that an input supplies within a few millionths of a -pound of the full rate can tie with it once the model stores both as float32, -and then the tie order can pick a different partner for each part of the year. +the full rate. An input that supplies the earnings above the limit bypasses +that reader: a tapered award within a few millionths of a pound of the full +rate then ties with it once the model stores both as float32, and the tie +order can pick a different partner for each part of the year. A supplied award +carries no dates, so the restriction keeps one recipient for the whole year. The parts of the year reach the payment as shares of the year: `carers_allowance_pre_overlap` is the amount from `carers_allowance_pre_couple_limit` that the restriction keeps, and diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 7c4f60ec3e..872e073e09 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -158,11 +158,11 @@ Budget 2026's costing says that on 1 July 2027 "fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI)", and applies the cut to other fuels in proportion. So on 1 July 2027 the gas rates return to their full rates before the 2022 cut (LPG £0.3161/kg, natural gas -£0.2470/kg) uprated by the same RPI growth as petrol and diesel. HM -Treasury publishes the rates from 1 July 2027 in Annex A of Budget 2026's -Overview of Tax Legislation and Rates: £0.3305/kg and £0.2582/kg (the -uprating gives £0.2583/kg for natural gas, and the model uses HM Treasury's -rate). Each 1 April from 2028 they rise by the previous calendar +£0.2470/kg), with the same RPI growth as petrol and diesel. HM Treasury +publishes the rates from 1 July 2027 in Annex A of Budget 2026's Overview +of Tax Legislation and Rates: £0.3305/kg and £0.2582/kg (the uprating gives +£0.2583/kg for natural gas, and the model uses HM Treasury's rate). Each +1 April from 2028 they rise by the previous calendar year's RPI growth. `calculate_fuel_duty_rates.py` writes these steps to 1 April 2031, as it does for petrol and diesel, and the parameters' uprating metadata continues them. That is policy, not law. diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index 42bc8ccb1c..118218e4ff 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -7,21 +7,22 @@ uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be uprated annually in line with RPI." The OBR forecasts the 1 July 2027 petrol and diesel rate (CUT_END_RATE: 60.59p in the October -2026 EFO): the 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI -growth in the year to 2027 Q1 (CUT_END_RPI_GROWTH). LPG and natural gas +2026 EFO) by applying RPI growth in the year to 2027 Q1 (CUT_END_RPI_GROWTH) +to the 57.95p pre-cut rate (Finance Act 2011 s. 19). LPG and natural gas take the proportionate cut to the same date, so their full rates get the same uprating then. HM Treasury publishes the rates from 1 July 2027 in Budget 2026's Overview of Tax Legislation and Rates, Annex A -(PUBLISHED_RATES), and the script writes those: each is within a hundredth -of a penny of the RPI uprating, and the script stops if one isn't. Natural -gas is the one that differs: 24.7p x 1.045565 rounds half up to 25.83p, and -HM Treasury publishes 25.82p. From 1 April 2028 the script applies each previous -calendar year's RPI growth, using economic_assumptions/yoy_growth.yaml. It -rounds to the nearest hundredth of a penny and writes the results between -the BEGIN and END markers in each file. After a new EFO, set CUT_END_RATE -and CUT_END_RPI_GROWTH to its forecast, and run it with --update whenever -they or the RPI forecast change; test_fuel_duty_schedule.py fails until you -do. +(PUBLISHED_RATES), and the script writes those: each must lie within a +hundredth of a penny of the RPI uprating, and the script stops if one +doesn't. Natural gas is the one that differs: 24.7p x 1.045565 rounds half +up to 25.83p, and HM Treasury publishes 25.82p. From 1 April 2028 the +script applies each previous calendar year's RPI growth, using +economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of +a penny and writes the results between the BEGIN and END markers in each +file. After a new EFO, set CUT_END_RATE and CUT_END_RPI_GROWTH to its +forecast, and PUBLISHED_RATES to any rates HM Treasury publishes in their +place, and run it with --update whenever they or the RPI forecast change; +test_fuel_duty_schedule.py fails until you do. Usage: uv run python calculate_fuel_duty_rates.py # print the steps @@ -124,20 +125,20 @@ def render(fuel: str, steps: list) -> str: how = ( "the same" if uprated == rate - else f"{uprated}, rounded half up; HM Treasury's rate is a " - "hundredth of a penny lower" + else f"{uprated} to the nearest hundredth of a penny; HM " + "Treasury's rate falls a hundredth of a penny short" ) if fuel == "petrol_and_diesel.yaml": note = ( f"HM Treasury's rate from 1 July 2027. The OBR's forecast, " - f"{previous} uprated by RPI growth in the year to 2027 Q1 " - f"({pct}), gives {how}." + f"RPI growth in the year to 2027 Q1 ({pct}) on {previous}, " + f"gives {how}." ) title = "OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)" else: note = ( - f"HM Treasury's rate from 1 July 2027. {previous} uprated " - f"by RPI growth in the year to 2027 Q1 ({pct}), as the OBR " + f"HM Treasury's rate from 1 July 2027. RPI growth in the " + f"year to 2027 Q1 ({pct}) on {previous}, as the OBR " f"uprates petrol and diesel, gives {how}." ) title = ( @@ -151,8 +152,8 @@ def render(fuel: str, steps: list) -> str: ] else: note = ( - f"Forecast. {previous} uprated by {int(date[:4]) - 1} RPI " - f"growth of {growth * 100:.1f}%." + f"Forecast. {int(date[:4]) - 1} RPI growth of " + f"{growth * 100:.1f}% on {previous}." ) source = [ f" - title: {forecast['title']} (RPI forecast)", @@ -191,12 +192,19 @@ def main(): ) args = parser.parse_args() for fuel in FUELS: + if fuel == "petrol_and_diesel.yaml": + uprated = uprate(base_rate(fuel), CUT_END_RPI_GROWTH) + if uprated != CUT_END_RATE: + raise ValueError( + f"CUT_END_RPI_GROWTH gives {uprated} on {CUT_END_DATE}, " + f"not the OBR's {CUT_END_RATE}" + ) + if PUBLISHED_RATES[fuel] != CUT_END_RATE: + raise ValueError( + f"PUBLISHED_RATES gives {PUBLISHED_RATES[fuel]} on " + f"{CUT_END_DATE}, not the OBR's {CUT_END_RATE}" + ) steps = forecast_steps(fuel) - if fuel == "petrol_and_diesel.yaml" and steps[0][1] != CUT_END_RATE: - raise ValueError( - f"CUT_END_RPI_GROWTH gives {steps[0][1]} on {CUT_END_DATE}, not " - f"the OBR's {CUT_END_RATE}" - ) print(f"{fuel}: {FUELS[fuel]} {base_rate(fuel)} (full rate)") for date, rate, previous, growth in steps: if growth is None: diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index 7feaf0be7d..13ec94cdf8 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -12,7 +12,7 @@ values: 2027-07-01: value: 0.3305 metadata: - note: HM Treasury's rate from 1 July 2027. 0.3161 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel, gives the same. + note: HM Treasury's rate from 1 July 2027. RPI growth in the year to 2027 Q1 (4.56%) on 0.3161, as the OBR uprates petrol and diesel, gives the same. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -25,7 +25,7 @@ values: 2028-04-01: value: 0.3448 metadata: - note: Forecast. 0.3305 uprated by 2027 RPI growth of 4.3%. + note: Forecast. 2027 RPI growth of 4.3% on 0.3305. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -36,7 +36,7 @@ values: 2029-04-01: value: 0.3552 metadata: - note: Forecast. 0.3448 uprated by 2028 RPI growth of 3.0%. + note: Forecast. 2028 RPI growth of 3.0% on 0.3448. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -47,7 +47,7 @@ values: 2030-04-01: value: 0.3658 metadata: - note: Forecast. 0.3552 uprated by 2029 RPI growth of 3.0%. + note: Forecast. 2029 RPI growth of 3.0% on 0.3552. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -58,7 +58,7 @@ values: 2031-04-01: value: 0.3742 metadata: - note: Forecast. 0.3658 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 2030 RPI growth of 2.3% on 0.3658. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index 9998a9fc4d..51ac4f3eed 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -5,15 +5,15 @@ values: # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; # policy costings page 13). The full rate then returns uprated by RPI # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel - # (HM Treasury publishes 25.82p, a hundredth of a penny below the uprated - # 25.83p), and rises by the previous calendar year's RPI growth each + # (HM Treasury publishes 25.82p, a hundredth of a penny short of the + # uprating's 25.83p), and rises by the previous calendar year's RPI growth each # 1 April from 2028. That is policy, not law. After the last entry, the # uprating metadata below continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py 2027-07-01: value: 0.2582 metadata: - note: HM Treasury's rate from 1 July 2027. 0.247 uprated by RPI growth in the year to 2027 Q1 (4.56%), as the OBR uprates petrol and diesel, gives 0.2583, rounded half up; HM Treasury's rate is a hundredth of a penny lower. + note: HM Treasury's rate from 1 July 2027. RPI growth in the year to 2027 Q1 (4.56%) on 0.247, as the OBR uprates petrol and diesel, gives 0.2583 to the nearest hundredth of a penny; HM Treasury's rate falls a hundredth of a penny short. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -26,7 +26,7 @@ values: 2028-04-01: value: 0.2694 metadata: - note: Forecast. 0.2582 uprated by 2027 RPI growth of 4.3%. + note: Forecast. 2027 RPI growth of 4.3% on 0.2582. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -37,7 +37,7 @@ values: 2029-04-01: value: 0.2776 metadata: - note: Forecast. 0.2694 uprated by 2028 RPI growth of 3.0%. + note: Forecast. 2028 RPI growth of 3.0% on 0.2694. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -48,7 +48,7 @@ values: 2030-04-01: value: 0.2859 metadata: - note: Forecast. 0.2776 uprated by 2029 RPI growth of 3.0%. + note: Forecast. 2029 RPI growth of 3.0% on 0.2776. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -59,7 +59,7 @@ values: 2031-04-01: value: 0.2924 metadata: - note: Forecast. 0.2859 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 2030 RPI growth of 2.3% on 0.2859. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index cdbaf066f1..548d0c090a 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -106,7 +106,7 @@ values: 2027-07-01: value: 0.6059 metadata: - note: HM Treasury's rate from 1 July 2027. The OBR's forecast, 0.5795 uprated by RPI growth in the year to 2027 Q1 (4.56%), gives the same. + note: HM Treasury's rate from 1 July 2027. The OBR's forecast, RPI growth in the year to 2027 Q1 (4.56%) on 0.5795, gives the same. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -119,7 +119,7 @@ values: 2028-04-01: value: 0.6321 metadata: - note: Forecast. 0.6059 uprated by 2027 RPI growth of 4.3%. + note: Forecast. 2027 RPI growth of 4.3% on 0.6059. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -130,7 +130,7 @@ values: 2029-04-01: value: 0.6513 metadata: - note: Forecast. 0.6321 uprated by 2028 RPI growth of 3.0%. + note: Forecast. 2028 RPI growth of 3.0% on 0.6321. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -141,7 +141,7 @@ values: 2030-04-01: value: 0.6707 metadata: - note: Forecast. 0.6513 uprated by 2029 RPI growth of 3.0%. + note: Forecast. 2029 RPI growth of 3.0% on 0.6513. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 @@ -152,7 +152,7 @@ values: 2031-04-01: value: 0.6861 metadata: - note: Forecast. 0.6707 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 2030 RPI growth of 2.3% on 0.6707. reference: - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index a037b7a8c9..5d288cc67c 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -29,7 +29,7 @@ - the payment reads the award before the couple limit only where the model calculates the award before overlap, so a supplied pre-overlap award needs nothing before it; -- an overlapping benefit equal to the award leaves nothing paid or received, +- an overlapping benefit equal to the award leaves no payment or receipt, the model pays a penny left over, and any payment is at least half a penny. """ diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index 4334a1198b..c52352dd95 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -134,8 +134,8 @@ def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate, published): OBR's 1 July uprating for petrol and diesel, 57.95p to 60.59p, and RPI each 1 April from 2028. HM Treasury publishes the 1 July 2027 rates (Overview of Tax Legislation and Rates, Annex A), and the schedule uses - them: LPG's is the uprated rate, and natural gas's 25.82p is a hundredth - of a penny below the uprated 25.83p.""" + them: LPG's matches the uprating, and natural gas's 25.82p falls a + hundredth of a penny short of the uprating's 25.83p.""" parameters = raw_parameters() rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi schedule = getattr(parameters.gov.hmrc.fuel_duty, fuel) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 8fdf95de42..66d3edd672 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -28,8 +28,8 @@ class carers_allowance_pre_overlap(Variable): "year the Budget 2026 earnings taper starts, the couple restriction " "works on the weeks before the taper and the weeks under it " "separately; a partner below the earnings limit has the larger award " - "in both, so, of the awards the model calculates, one partner has the " - "award for the year. A " + "in both, so, where the model reads the earnings above the limit " + "itself, one partner has the award for the year. A " "carers_allowance_pre_couple_limit that an input supplies, or that a " "reform neutralizes, calculates with a formula of its own or " "otherwise changes (with defined_for, say), carries no dates, so the " From ce9b262ff544c405b07b53d1f80d73ed4da084d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 03:42:02 -0400 Subject: [PATCH 27/29] Answer review round 11: break a weekly tie by the award over the year In 2030-31 the couple election runs on the weeks before the taper and the weeks under it separately. Where a head over the earnings limit had the full rate under the taper (a reform setting the taper rate to zero, an input or reform setting is_gainfully_employed_for_carers_allowance, or an excess of a millionth of a pound), the two awards tied, the tie order gave the head the taper weeks and the partner the weeks before, both partners counted as entitled, and the Pension Credit carer addition doubled. Where both partners have the same award for a part of the year, the couple now elect the one with the larger award over the year, then #2632's order. A partner below the limit keeps the award all year at any taper rate of zero or more. At the Budget's taper rate, with the model's own earnings, partners on either side of the limit never tie and partners on the same side have equal annual awards, so nothing changes there. Tests: a property at taper rates of 0 and 0.0001, and three examples (zero taper rate, supplied gainful-employment flag, a millionth over the limit) checking the partner keeps the award with one carer addition. All four fail without the tie-break. Also: the fuel duty script checks all three fuels before writing any file, says what to do when HM Treasury's rates no longer match a new forecast, and states that petrol and diesel's published rate must equal the uprating; the assumptions page names the stage whose supplied value keeps one recipient and drops the float32 account; reduced passives in the excise docs, fuel duty comments and changelog. Co-Authored-By: Claude Opus 5.5 --- .../ab26-carers-allowance-taper.changed.md | 2 +- .../carer-support-payment-earnings.md | 18 +-- docs/engineering/excise-duties.md | 10 +- .../fuel_duty/calculate_fuel_duty_rates.py | 44 ++++--- .../parameters/gov/hmrc/fuel_duty/lpg.yaml | 8 +- .../gov/hmrc/fuel_duty/natural_gas.yaml | 8 +- .../gov/hmrc/fuel_duty/petrol_and_diesel.yaml | 6 +- .../test_carers_allowance_earnings_taper.py | 108 +++++++++++++++--- .../utils/carers_allowance_taper.py | 8 +- policyengine_uk/utils/couple_carer_award.py | 19 ++- .../gov/dwp/carers_allowance_pre_overlap.py | 7 +- 11 files changed, 165 insertions(+), 73 deletions(-) diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md index 280fa7c1dd..c1fd54f4cd 100644 --- a/changelog.d/ab26-carers-allowance-taper.changed.md +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -1 +1 @@ -Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper announced at Budget 2026, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and, where the model reads earnings itself, one partner has the award for the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper that Budget 2026 announced, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and one partner has the award for the year: where the two have the same award for some weeks, the couple elect the one with the larger award over the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index 57bc21d25a..9b03c6edf0 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -104,17 +104,17 @@ take-up conditions stay as they are. In 2030-31, the year the taper starts, the model applies the election separately to the weeks before the taper and the weeks under it, since the law -restricts entitlement day by day. A partner below the earnings limit has the -full rate in both, so the couple elect them for the whole year. Where both +restricts entitlement day by day. Where the two partners have the same award +for those weeks, the couple elect the one with the larger award over the year, +and then the order above applies. A partner below the earnings limit has the +full rate in both parts and the larger award over the year, so the couple +elect them for the whole year, even where the other partner's award under the +taper equals the full rate, as it does at a taper rate of zero. Where both partners earn over the limit, neither has an award before the taper, and the couple elect the one with the larger award under it. Either way one partner -has the award for the year: the model reads earnings in whole pence against a -limit in whole pence, so its own tapered award falls at least a penny short of -the full rate. An input that supplies the earnings above the limit bypasses -that reader: a tapered award within a few millionths of a pound of the full -rate then ties with it once the model stores both as float32, and the tie -order can pick a different partner for each part of the year. A supplied award -carries no dates, so the restriction keeps one recipient for the whole year. +has the award for the year, at any taper rate of zero or more. A +`carers_allowance_pre_couple_limit` that an input supplies carries no dates, +so the restriction keeps one partner's award for the whole year. The parts of the year reach the payment as shares of the year: `carers_allowance_pre_overlap` is the amount from `carers_allowance_pre_couple_limit` that the restriction keeps, and diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 872e073e09..ada614d612 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -19,10 +19,10 @@ complete 2023/24 calculation are outside this implementation. Tobacco rates start on 22 November 2023. The new alcohol, tobacco and car formulas return their default zero before 2024; that is an unmodelled period, not a statutory exemption. Gas duty covers 2022/23 onwards. SI 2026/555 removed the September 2026 step -announced at Budget 2025 and moved the December 2026 step to 1 January 2027. +that Budget 2025 announced and moved the December 2026 step to 1 January 2027. Budget 2026 then kept the proportionate cut to 1 July 2027, when the gas rates -return to their full levels uprated by RPI, and rise by RPI each 1 April from -2028, as petrol and diesel do (see below). +return to their full levels with RPI growth on top, and rise by RPI each 1 April +from 2028, as petrol and diesel do (see below). For alcohol and tobacco, a model year such as `2026` represents 6 April 2026 to 5 April 2027. Quantities and cigarette retail prices are constant across that @@ -184,8 +184,8 @@ cut to 1 July 2027. Its costing (page 13) says: "On 1 July 2027, fuel duty rates will increase by 5ppl and will be uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be uprated annually in line with RPI." That is policy, not law. The OBR's October 2026 EFO (paragraph 3.19) -puts the rate at 60.59p from 1 July 2027: the 57.95p pre-cut rate uprated by -RPI growth in the year to 2027 Q1 (4.5565%, economy Table 1.7). The model +puts the rate at 60.59p from 1 July 2027: RPI growth in the year to 2027 Q1 +(4.5565%, economy Table 1.7) on the 57.95p pre-cut rate. The model uses that figure, and `calculate_fuel_duty_rates.py` refuses to write any other July rate. From 1 April 2028, each step applies the previous calendar year's RPI growth from the OBR forecast in diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index 118218e4ff..6a499a8bbc 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -12,9 +12,9 @@ take the proportionate cut to the same date, so their full rates get the same uprating then. HM Treasury publishes the rates from 1 July 2027 in Budget 2026's Overview of Tax Legislation and Rates, Annex A -(PUBLISHED_RATES), and the script writes those: each must lie within a -hundredth of a penny of the RPI uprating, and the script stops if one -doesn't. Natural gas is the one that differs: 24.7p x 1.045565 rounds half +(PUBLISHED_RATES), and the script writes those. Each must lie within a +hundredth of a penny of the RPI uprating, and petrol and diesel's must +equal both the uprating and CUT_END_RATE; the script stops if one doesn't. Natural gas is the one that differs: 24.7p x 1.045565 rounds half up to 25.83p, and HM Treasury publishes 25.82p. From 1 April 2028 the script applies each previous calendar year's RPI growth, using economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of @@ -22,7 +22,12 @@ file. After a new EFO, set CUT_END_RATE and CUT_END_RPI_GROWTH to its forecast, and PUBLISHED_RATES to any rates HM Treasury publishes in their place, and run it with --update whenever they or the RPI forecast change; -test_fuel_duty_schedule.py fails until you do. +test_fuel_duty_schedule.py fails until you do. If HM Treasury publishes no +new rates and the new uprating moves one of the old ones by more than a +hundredth of a penny, the script stops: decide which to follow, and to +follow the OBR, set PUBLISHED_RATES to the uprating and change the notes and +references, which credit HM Treasury. The script checks every fuel before it +writes any file, so a stop leaves them all as they were. Usage: uv run python calculate_fuel_duty_rates.py # print the steps @@ -191,20 +196,20 @@ def main(): help="Rewrite the forecast block in each fuel duty parameter file", ) args = parser.parse_args() - for fuel in FUELS: - if fuel == "petrol_and_diesel.yaml": - uprated = uprate(base_rate(fuel), CUT_END_RPI_GROWTH) - if uprated != CUT_END_RATE: - raise ValueError( - f"CUT_END_RPI_GROWTH gives {uprated} on {CUT_END_DATE}, " - f"not the OBR's {CUT_END_RATE}" - ) - if PUBLISHED_RATES[fuel] != CUT_END_RATE: - raise ValueError( - f"PUBLISHED_RATES gives {PUBLISHED_RATES[fuel]} on " - f"{CUT_END_DATE}, not the OBR's {CUT_END_RATE}" - ) - steps = forecast_steps(fuel) + petrol = "petrol_and_diesel.yaml" + uprated = uprate(base_rate(petrol), CUT_END_RPI_GROWTH) + if uprated != CUT_END_RATE: + raise ValueError( + f"CUT_END_RPI_GROWTH gives {uprated} on {CUT_END_DATE}, " + f"not the OBR's {CUT_END_RATE}" + ) + if PUBLISHED_RATES[petrol] != CUT_END_RATE: + raise ValueError( + f"PUBLISHED_RATES gives {PUBLISHED_RATES[petrol]} on " + f"{CUT_END_DATE}, not the OBR's {CUT_END_RATE}" + ) + schedules = {fuel: forecast_steps(fuel) for fuel in FUELS} + for fuel, steps in schedules.items(): print(f"{fuel}: {FUELS[fuel]} {base_rate(fuel)} (full rate)") for date, rate, previous, growth in steps: if growth is None: @@ -215,7 +220,8 @@ def main(): else: how = f"{previous} x (1 + {growth})" print(f" {date}: {rate} ({how})") - if args.update: + if args.update: + for fuel, steps in schedules.items(): update(fuel, steps) diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index 13ec94cdf8..3839056d64 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -3,10 +3,10 @@ values: 2021-01-01: 0.3161 2022-03-23: 0.2888 # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; - # policy costings page 13). The full rate then returns uprated by RPI - # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel, - # and rises by the previous calendar year's RPI growth each 1 April from - # 2028. That is policy, not law. After the last entry, the uprating + # policy costings page 13). The full rate then returns with RPI + # growth in the year to 2027 Q1 on top, as the OBR uprates petrol and + # diesel, and rises by the previous calendar year's RPI growth each 1 April + # from 2028. That is policy, not law. After the last entry, the uprating # metadata below continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py 2027-07-01: diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index 51ac4f3eed..ca31a4fb55 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -3,11 +3,11 @@ values: 2021-01-01: 0.247 2022-03-23: 0.2257 # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; - # policy costings page 13). The full rate then returns uprated by RPI - # growth in the year to 2027 Q1, the OBR's uprating for petrol and diesel + # policy costings page 13). The full rate then returns with RPI + # growth in the year to 2027 Q1 on top, as the OBR uprates petrol and diesel # (HM Treasury publishes 25.82p, a hundredth of a penny short of the - # uprating's 25.83p), and rises by the previous calendar year's RPI growth each - # 1 April from 2028. That is policy, not law. After the last entry, the + # uprating's 25.83p), and rises by the previous calendar year's RPI growth + # each 1 April from 2028. That is policy, not law. After the last entry, the # uprating metadata below continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py 2027-07-01: diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index 548d0c090a..a47d8db7dc 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -95,9 +95,9 @@ values: # duty rates will increase by 5ppl and will be uprated in line with the # Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be # uprated annually in line with RPI." That is policy, not law. The 1 July - # 2027 step is the OBR's forecast (EFO October 2026, paragraph 3.19): the - # 57.95p pre-cut rate (Finance Act 2011 s. 19) uprated by RPI growth in the - # year to 2027 Q1, 60.59p. Each 1 April from 2028 applies the previous + # 2027 step is the OBR's forecast (EFO October 2026, paragraph 3.19): RPI + # growth in the year to 2027 Q1 on the 57.95p pre-cut rate (Finance Act 2011 + # s. 19), 60.59p. Each 1 April from 2028 applies the previous # calendar year's RPI growth from # gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest # hundredth of a penny. After the last entry, the uprating metadata below diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 5d288cc67c..3a7034ba79 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -18,8 +18,8 @@ person; - a couple caring for the same person elect the larger award, so the taper never leaves a couple with less than the cliff edge, and one partner has - the award for the year, in 2030-31 too, even where a reform masks the - other's fraction of a penny; + the award for the year, in 2030-31 too, at any taper rate of zero or more + and even where a reform masks the other's fraction of a penny; - an award that an input supplies, or that a reform neutralizes, calculates with a formula of its own or masks with defined_for, before the couple limit or the overlap rule is what reaches the payment, with the whole @@ -304,10 +304,11 @@ def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( # A couple caring for the same person get one award at a time, and elect -# which of them has it: the model takes the larger award, the head's where -# the two are equal. In 2030-31 the couple restriction works on the weeks -# before the taper and the weeks under it separately, and a partner below -# the limit has the larger award in both. +# which of them has it: the model takes the larger award, then the larger +# award over the year, then the head's. In 2030-31 the couple restriction +# works on the weeks before the taper and the weeks under it separately, and +# a partner below the limit has the larger award in both, or an equal one +# and the larger award over the year. def couple(head_weekly_earnings, partner_weekly_earnings, weekly_state_pension): @@ -378,6 +379,48 @@ def test_a_couple_under_the_limit_gets_one_award_all_year(): assert partner == 0 +# Under the taper, a head over the limit can have the full rate too: at a +# taper rate of zero, where an input says they earn over the limit with no +# earnings above it, or with a millionth of a pound above it, which float32 +# loses. The couple then elect the partner, who has the award all year, +# rather than giving the head the weeks under the taper. + + +def full_rate_under_the_taper(route): + limit, _ = ca_parameters(START_YEAR) + if route == "zero taper rate": + reform = {f"{TAPER}.rate": {str(START_YEAR): 0}} + return couple(limit + 50, limit - 4, 0), reform + situation = couple(limit - 4, limit - 4, 0) + head = situation["people"]["head"] + if route == "supplied earnings condition": + head["is_gainfully_employed_for_carers_allowance"] = {START_YEAR: True} + else: + head["carers_allowance_weekly_earnings_above_limit"] = {START_YEAR: 1e-6} + return situation, None + + +@pytest.mark.parametrize( + "route", ["zero taper rate", "supplied earnings condition", "millionth over"] +) +def test_a_head_on_the_full_rate_under_the_taper_leaves_the_partner_the_year(route): + limit, rate = ca_parameters(START_YEAR) + situation, reform = full_rate_under_the_taper(route) + sim = Simulation(situation=situation, reform=reform) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", START_YEAR) + assert pre_overlap.astype(float) == pytest.approx([0, rate * 52], abs=0.05) + entitled = sim.calculate("is_entitled_to_carer_benefit", START_YEAR) + assert list(entitled) == [False, True] + # One carer's Pension Credit addition, as for a couple both under the + # limit. + one_carer = Simulation(situation=couple(limit - 4, limit - 4, 0)) + addition = "carer_minimum_guarantee_addition" + assert sim.calculate(addition, START_YEAR) == pytest.approx( + one_carer.calculate(addition, START_YEAR) + ) + assert sim.calculate(addition, START_YEAR) > 0 + + # A supplied pre-overlap award carries no dates, so it meets the whole year's # overlapping benefits, whatever the earnings condition says. @@ -919,8 +962,8 @@ def test_an_overlapping_benefit_equal_to_the_award_leaves_nothing_received( assert not sim.calculate("receives_carers_allowance", year).any() -# Whole pence, so the two partners' awards are equal or at least a penny -# apart, wherever float32 rounds them. +# Whole pence, so at the Budget's taper rate the two partners' weekly awards +# are equal or differ by about a penny or more, after float32 rounding. pence = weekly.map(lambda earnings: round(earnings, 2)) couple_cases = st.lists( st.tuples( @@ -987,16 +1030,18 @@ def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( regions = np.array([REGIONS[i % len(REGIONS)] for i in range(CASES)]) tapers = np.isin(regions, TAPER_REGIONS)[:, None] pension = pension.reshape(-1, 2) + before = np.where(above > 0, 0, rate) + under = np.where(above > 0, np.where(tapers, np.maximum(0, rate - above), 0), rate) + parts = ((1 - START_YEAR_SHARE, before), (START_YEAR_SHARE, under)) + annual = sum(share * weekly for share, weekly in parts) expected = np.zeros_like(paid) - for share, under_taper in ((1 - START_YEAR_SHARE, False), (START_YEAR_SHARE, True)): - if under_taper: - weekly = np.where( - above > 0, np.where(tapers, np.maximum(0, rate - above), 0), rate - ) - else: - weekly = np.where(above > 0, 0, rate) - # The larger award, the head's where the two are equal. - head_paid = (weekly[:, 0] > 0) & (weekly[:, 0] >= weekly[:, 1]) + for share, weekly in parts: + # The larger award, then the larger award over the year, then the + # head's. + head_paid = (weekly[:, 0] > 0) & ( + (weekly[:, 0] > weekly[:, 1]) + | ((weekly[:, 0] == weekly[:, 1]) & (annual[:, 0] >= annual[:, 1])) + ) selected = np.column_stack([head_paid, ~head_paid]) expected += share * 52 * selected * np.maximum(0, weekly - pension) # Scotland pays Carer Support Payment instead. @@ -1056,3 +1101,32 @@ def test_the_taper_never_leaves_a_couple_with_less_than_the_cliff_edge( .reshape(-1, 2) ) assert pre_overlap.sum(axis=1) == pytest.approx(candidate.max(axis=1)) + + +# At a taper rate of zero, or one small enough that float32 loses it, a +# partner over the limit has the full rate under the taper, the same award as +# a partner under it; one partner still has the award for the year. + + +@pytest.fixture(scope="module") +def taper_rate_sims(): + situation = couples_situation() + return [ + Simulation(situation=situation, reform={f"{TAPER}.rate": {str(START_YEAR): r}}) + for r in (0, 0.0001) + ] + + +@SETTINGS +@given(couple_cases) +def test_one_partner_has_the_award_for_the_year_at_any_taper_rate( + taper_rate_sims, drawn +): + head_earnings, partner_earnings, _, _ = map(np.array, zip(*drawn)) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + for sim in taper_rate_sims: + sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + sim.reset_calculations() + for name in ("carers_allowance_pre_overlap", "is_entitled_to_carer_benefit"): + award = sim.calculate(name, START_YEAR).reshape(-1, 2) + assert ((award > 0).sum(axis=1) <= 1).all() diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index 0e9efa48f6..e72d9a1de5 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -81,10 +81,14 @@ def carers_allowance_annual_award(parts): def carers_allowance_shares_of_year(person, period, parts): """From carers_allowance_weekly_by_part_of_year's parts: the share of the year in which the person has an award before the couple restriction, and - the share in which the restriction leaves it with them.""" + the share in which the restriction leaves it with them. Where both + partners have the same weekly award in a part, the restriction prefers the + one with the larger award over the year, so a partner below the earnings + limit keeps the award all year at any taper rate of zero or more.""" + annual = carers_allowance_annual_award(parts) candidate = kept = 0 for share, weekly in parts: - selected = limit_carer_benefit_to_one_partner(person, period, weekly) + selected = limit_carer_benefit_to_one_partner(person, period, weekly, annual) candidate = candidate + share * (weekly > 0) kept = kept + share * (selected > 0) return candidate, kept diff --git a/policyengine_uk/utils/couple_carer_award.py b/policyengine_uk/utils/couple_carer_award.py index b1ac21481a..9900879c5a 100644 --- a/policyengine_uk/utils/couple_carer_award.py +++ b/policyengine_uk/utils/couple_carer_award.py @@ -39,23 +39,30 @@ def unlimited_carer_benefit_entitlement(person, period): return (amounts[0] + amounts[1] > 0) | receives -def limit_carer_benefit_to_one_partner(person, period, candidate): +def limit_carer_benefit_to_one_partner(person, period, candidate, annual=None): """One candidate award for a same-target couple, before overlap reductions. The couple jointly elect which of them has the entitlement (SSCBA 1992 s.70(7ZA)(a)). The data don't record the election, so the model takes the one that gives the larger award, as it does for other elections it models. The model's own awards differ only under the Carer's Allowance earnings - taper, though supplied awards can differ in any year; where they are - equal, prefer an eligible reported recipient, then an eligible - benefit-unit head, then the eldest eligible partner (stable member order - breaks age ties). Other members and units with other than two - claimant/partner flags are outside this couple-only restriction. + taper, though supplied awards can differ in any year. In the year the + taper starts, the restriction runs on each part of the year, and annual, + each person's award over the whole year, decides between equal awards in + a part, so a partner with the award all year keeps it in weeks where the + other's award equals theirs. Where the awards are still equal, prefer an + eligible reported recipient, then an eligible benefit-unit head, then the + eldest eligible partner (stable member order breaks age ties). Other + members and units with other than two claimant/partner flags are outside + this couple-only restriction. """ benunit = person.benunit partner = person("is_claimant_or_partner", period) largest = benunit.max(np.where(partner, candidate, 0)) eligible = partner & (candidate > 0) & (candidate >= largest) + if annual is not None: + largest_annual = benunit.max(np.where(eligible, annual, 0)) + eligible = eligible & (annual >= largest_annual) reported = eligible & (person("carers_allowance_reported", period) > 0) preferred = np.where(benunit.any(reported), reported, eligible) head = preferred & person("is_benunit_head", period) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 66d3edd672..b0be0ee2b7 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -27,9 +27,10 @@ class carers_allowance_pre_overlap(Variable): "test entitlement. carers_allowance is the amount payable. In the " "year the Budget 2026 earnings taper starts, the couple restriction " "works on the weeks before the taper and the weeks under it " - "separately; a partner below the earnings limit has the larger award " - "in both, so, where the model reads the earnings above the limit " - "itself, one partner has the award for the year. A " + "separately, and where the two partners' weekly awards are equal, it " + "prefers the larger award over the year; a partner below the earnings " + "limit keeps the award in both, so one partner has the award for the " + "year at any taper rate of zero or more. A " "carers_allowance_pre_couple_limit that an input supplies, or that a " "reform neutralizes, calculates with a formula of its own or " "otherwise changes (with defined_for, say), carries no dates, so the " From db33fab06bc61c0a102de314df976dc38c5b30d2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 04:26:27 -0400 Subject: [PATCH 28/29] Answer review round 12: say where float32 rounds the award Float32 keeps a millionth of a pound of excess; it is the tapered award that rounds to the full rate when float32 takes the excess from it. The two test comments now say so, and name the case a 0.0001 taper rate covers (a head a penny over the limit). Comments only. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_carers_allowance_earnings_taper.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 3a7034ba79..20fc09c071 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -381,9 +381,10 @@ def test_a_couple_under_the_limit_gets_one_award_all_year(): # Under the taper, a head over the limit can have the full rate too: at a # taper rate of zero, where an input says they earn over the limit with no -# earnings above it, or with a millionth of a pound above it, which float32 -# loses. The couple then elect the partner, who has the award all year, -# rather than giving the head the weeks under the taper. +# earnings above it, or with a millionth of a pound above it, which rounds +# away when float32 takes it from the rate. The couple then elect the +# partner, who has the award all year, rather than giving the head the weeks +# under the taper. def full_rate_under_the_taper(route): @@ -1103,9 +1104,10 @@ def test_the_taper_never_leaves_a_couple_with_less_than_the_cliff_edge( assert pre_overlap.sum(axis=1) == pytest.approx(candidate.max(axis=1)) -# At a taper rate of zero, or one small enough that float32 loses it, a -# partner over the limit has the full rate under the taper, the same award as -# a partner under it; one partner still has the award for the year. +# At a taper rate of zero, a partner over the limit has the full rate under +# the taper, the same award as a partner under it; at 0.0001, so does one a +# penny over the limit, whose tapered award rounds to the full rate in +# float32. One partner still has the award for the year. @pytest.fixture(scope="module") From c086c4917757dcf2bc31b86acf9371881b615f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:48:36 +0000 Subject: [PATCH 29/29] Count the Household Energy Payment in the other investment income test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test came from #2746 after this branch's CI ran. Its default band D household in England now gets the £100 payment in 2026, so gross, net and HBAI income each rise by 100. Co-Authored-By: Claude Opus 5.5 --- ...investment_income_in_household_income.yaml | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml index 16ebc9e59f..1e0c707107 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml @@ -4,7 +4,9 @@ # interest. 2026-27: personal allowance £12,570, basic rate 20%, personal # savings allowance £1,000 for a basic rate taxpayer, starting rate for savings # £5,000 less non-savings income above the personal allowance (here nil), -# employee NI 8% from £12,570 to £50,270, TV licence £180. +# employee NI 8% from £12,570 to £50,270, TV licence £180, and the £100 +# Household Energy Payment (band D, the default, in England), which gross, +# net and HBAI income count as a benefit. - name: Other investment income counts in market, capital, net and HBAI income period: 2026 @@ -17,17 +19,18 @@ # 40,000 + 2,000. market_income: 42_000 household_market_income: 42_000 - household_gross_income: 42_000 + # Plus the 100 Household Energy Payment. + household_gross_income: 42_100 capital_income: 2_000 # (40,000 - 12,570) x 20% = 5,486, plus (2,000 - 1,000 PSA) x 20% = 200. income_tax: 5_686 # (40,000 - 12,570) x 8%. national_insurance: 2_194.40 - # 42,000 - 5,686 - 2,194.40. - hbai_household_net_income: 34_119.60 - # Less the 180 TV licence. Without the interest it is 32,139.60, so the + # 42,100 - 5,686 - 2,194.40. + hbai_household_net_income: 34_219.60 + # Less the 180 TV licence. Without the interest it is 32,239.60, so the # interest raises net income by 2,000 less its 200 of tax. - household_net_income: 33_939.60 + household_net_income: 34_039.60 - name: The same amount as bank interest gives the same incomes period: 2026 @@ -39,9 +42,9 @@ output: market_income: 42_000 household_market_income: 42_000 - household_gross_income: 42_000 + household_gross_income: 42_100 capital_income: 2_000 income_tax: 5_686 national_insurance: 2_194.40 - hbai_household_net_income: 34_119.60 - household_net_income: 33_939.60 + hbai_household_net_income: 34_219.60 + household_net_income: 34_039.60