diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 9206674edc..7e521a6724 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -64,3 +64,10 @@ nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_con outcome = "skipped" reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." + +[[allow]] +nodeid = "policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py::test_2024_annual_allowance_charge_and_payers_are_near_hmrc" +outcome = "xfailed" +reason = "policyengine-uk-data#606" +data = ["present"] +why = "Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance count, charge and excess several times above HMRC's; with a third of those contributions all three fall within the band. Pinned by a strict xfail until the data is fixed (#2523)." diff --git a/.github/workflows/pr_code_changes.yaml b/.github/workflows/pr_code_changes.yaml index 38fddb6ee7..e959391554 100644 --- a/.github/workflows/pr_code_changes.yaml +++ b/.github/workflows/pr_code_changes.yaml @@ -162,7 +162,7 @@ jobs: run: uv pip install policyengine --system - name: Test documentation builds run: make documentation - # The YAML tests, the Python tests on four runners, and the check that + # The YAML tests, the Python tests on five runners, and the check that # every test ran once and passed, in the locked environment. As one job # they took 59 minutes on 10 October 2026 (run 38075384588), and runs were # cancelled at a 60-minute limit (issues #2099 and #2257). The release diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index c7f7fa8067..dddd56c9d2 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -4,10 +4,10 @@ # publish. All three run the same jobs in the same locked environment. # # Jobs: the YAML policy tests (which also list every Python test), the Python -# tests split over four runners, and Result, which fails unless every listed +# tests split over five runners, and Result, which fails unless every listed # Python test ran in exactly one shard and passed, apart from the outcomes # accepted in .github/test_outcome_allowlist.toml. A caller that names its job -# Test gets the checks "Test / YAML", "Test / Python K/4" and "Test / Result"; +# Test gets the checks "Test / YAML", "Test / Python K/5" and "Test / Result"; # "Test / Result" is the one to require. name: Test suite @@ -82,6 +82,9 @@ jobs: TESTS: ${{ steps.env.outputs.tests }} run: policyengine-core test "$TESTS/policy" -c policyengine_uk - name: List the Python tests + id: collect + # Keep Result's coverage audit available after a policy YAML failure. + if: ${{ !cancelled() && steps.env.outcome == 'success' }} # Every Python test, collected without --shard, in the environment # the shards use. Result checks that the shards ran each of these # exactly once. @@ -99,6 +102,7 @@ jobs: --collect-only -q -p no:cacheprovider > collected.txt tail -n 1 collected.txt - name: Upload the list of Python tests + if: ${{ !cancelled() && steps.collect.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: test-collected @@ -118,17 +122,23 @@ jobs: # the 16 GB runners peaked at 15.6 GB, 283 MB short of running out # (run 38156967136; two shards of an earlier run lost their runners). # With four, each worker runs about a quarter fewer tests. + # + # Five, not four, for time: on 20 and 21 October shard 1/4 took 50 to + # 52 minutes on unrelated pull requests, close to the timeout. A pull + # request adding about a fifth more parameters (#1855) hit the timeout + # on shard 1/4 twice, and its other shards took 48 to 59 minutes (run + # 39568525017). name: Python ${{ matrix.shard }}/${{ strategy.job-total }} if: ${{ !inputs.skip }} runs-on: ubuntu-24.04-arm # The shards took 15, 20 and 15 minutes on 10 October (run - # 38085920460). 60 minutes leaves room for a slow runner and still - # stops a hung shard. + # 38085920460), and up to 52 minutes with four shards on 21 October. + # 60 minutes leaves room for a slow runner and still stops a hung shard. timeout-minutes: 60 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3, 4, 5] steps: - name: Checkout repo uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d5d012c1..a141f5f9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,190 @@ +## [2.146.2] - 2026-10-22 + +### Fixed + +- Retain both the minimum-age and earnings conditions when combining Carer Support Payment fixes. +- Applied the minimum ages to computed Carer's Allowance, Carer Support Payment and DLA mobility awards, preserving DLA care and explicit amount overrides. + + +## [2.146.1] - 2026-10-22 + +### Changed + +- - Store marginal National Insurance schedules as parameter scales while preserving contribution calculations and existing scalar reform paths. + - Reject unsupported changes to the fixed zero-rate, zero-threshold NI scale sentinel with a targeted error. + - Direct YAML readers must migrate the ten removed scalar files to the canonical scale files; parameter-tree readers retain the legacy compatibility paths. + - Retain incoming savings indexation, director and liability rules while migrating NI readers; hide legacy and fixed-zero discovery controls without removing their IDs or update contracts. +- - Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata. + +### Fixed + +- Preserve age-conditioned National Insurance deductions when validating Carer Support Payment earnings after main integration. +- Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. + + Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified. +- - Applied the Carer Support Payment earnings limit, including its own dated thresholds and favorable fractional-penny rule. +- Fix the default historical income-tax band thresholds and strict upper boundaries, including Marriage Allowance eligibility in 2016 and 2017. + + +## [2.146.0] - 2026-10-22 + +### Added + +- Add council_tax_band_d_amount and simulated_council_tax, computing gross council tax from local authority Band D amounts and statutory band ratios. Band D amounts cover all 350 April 2023 local authorities across England, Wales and Scotland for fiscal 2025 and 2026. + +### Fixed + +- Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. + + Keep each person's legacy closure false alongside the family closure when the report-only benefit reform holds migration off. + + +## [2.145.2] - 2026-10-22 + +### Fixed + +- Preserve cohabiting-couple participation groups when calculating responses for an explicit year. +- - Added Universal Credit benefit cap earnings exception regression tests (Universal Credit Regulations 2013 reg. 82), including the per-claimant earned income cases (tax on pension income, one partner's pension contributions, contributions after 75), and cited regs. 55 and 57 and Finance Act 2004 s. 188 on `is_uc_benefit_cap_exempt_earnings`. +- Use the requested year and eligible-only spillover bins in participation dynamics, preserving seeded MT19937 draws without changing global NumPy state. +- When Universal Credit rebalancing is inactive, uprate the LCWRA award from its pre-Act amount while preserving explicit inputs and refreshing cached awards after amount reforms. Preserve the benefit-cap claimant filter through the internal LCWRA amount wrapper while retaining explicit awards and external reforms. Anchored the inactive 2026-27 health-element counterfactual to September CPI with monthly-penny rounding, before projecting later years from that anchor. + + +## [2.145.1] - 2026-10-21 + +### Fixed + +- - Uprate additional State Pension and protected payments by September CPI, as the Up-rating Orders do (SI 2026/148 arts 4(3) and 6(3)(b)), not by the full rate's triple-lock rise; reforms to the full rates no longer move them. + + +## [2.145.0] - 2026-10-21 + +### Fixed + +- - Bus fares from journeys now follow DfT's published local bus fares index (BUS0415a) by area and fiscal year from the 2024-25 benchmark, and the OBR CPI after the last published quarter (March 2026), instead of holding the benchmark yield and adding a cap term against a fixed 2024 cap schedule. The 2025-26 yield per fare-paying boarding outside London rises 12.6% (the index) instead of 24.9%. Northern Ireland, which has no published index, follows CPI. Cap changes after the last published quarter (the £2 cap in 2027 and its end in 2028) enter the baseline through the cap term. The bus fare cap schedules now keep their calendar dates, so a cap change part-way through a year applies from its date. + - Cap changes are priced from a three-band table of uncapped single fares (`gov.dft.bus.uncapped_single_fares`), fitted to DfT's evaluation of the £2 cap, the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for the £3 cap in 2025 and the £2 cap in 2027, and uprated with CPI, instead of one £2.748 fare for every single ticket. A £3 cap now binds, and a full year at £2 against no cap saves about £0.36bn a year at 2024-25 boardings instead of £0.70bn. A supplied `local_bus_uncapped_single_fare` replaces the table for that person. + +### Removed + +- - Removed `gov.dft.bus.reference_year` and `gov.dft.bus.uncapped_single_fare`. The benchmark year of the published bus fare yield is `gov.dft.bus.benchmark_year`; the cap term reads `gov.dft.bus.fares.cap` and `gov.dft.bus.reference_fares.cap` in the same year; and cap changes are priced from `gov.dft.bus.uncapped_single_fares`. A reform naming a removed parameter fails with directions. + + +## [2.144.0] - 2026-10-21 + +### Added + +- Add `pip_dl_points` and `pip_m_points` person-level inputs and points-threshold parameters under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, and derive `pip_dl_category` and `pip_m_category` from each person's latest points when the category is not provided as input. A person's category supplied for an earlier year still carries forward unless that person has points for a later year. + + +## [2.143.0] - 2026-10-21 + +### Added + +- Added a PIP point system planning page (docs/book/programs/gov/dwp/pip-points-plan.md) setting out the SI 2013/377 scoring rules and a phased route to modelling them, starting from #1661. +- Added a regional childcare planning page (docs/book/programs/gov/dfe/regional-childcare-plan.md) listing the Scottish, Welsh and Northern Ireland childcare schemes not yet modelled and how to add them. +- Added a plan for modelling Cold Weather Payment and the Scottish Winter Heating Payment (#435) at `docs/book/programs/gov/dwp/cold-weather-payment-plan.md`. +- Added a plan for a rule-based Bereavement Support Payment and historical Widowed Parent's Allowance (#466, #465) at `docs/book/programs/gov/dwp/bereavement-support-plan.md`. +- Added a plan for modelling the Warm Home Discount (#502) at `docs/book/programs/gov/ofgem/warm-home-discount-plan.md`, reflecting the benefit-based eligibility in England and Wales from winter 2025-26 and the separate Scottish scheme. +- Added a page (docs/book/policy/future-date-announcements.md) on encoding announced policy changes as date-keyed parameter values, and how those dates become model-year values. +- Add a planning docs page for child maintenance (`docs/book/programs/gov/cms/child-maintenance.md`): how `maintenance_income` and `maintenance_expenses` are treated today, the gap for spousal maintenance in Universal Credit, and a route to modelling the Child Maintenance Service calculation. +- Add a documentation page (`docs/book/programs/gov/hmrc/pensions-comparison-stbm.md`) comparing PolicyEngine UK's treatment of pension contributions in income tax with the Scottish Tax-Benefit Model's source code, including a gap both share: contributions do not reduce adjusted net income for the personal allowance taper. +- Add a documentation page at `docs/book/programs/gov/hmrc/pension-tax-relief.md` explaining how PolicyEngine UK models personal pension tax relief (full marginal-rate reduction in taxable income via `pension_contributions_relief`, with separate annual-allowance excess charge via `personal_pension_contributions_tax`), and clarifying that the model correctly reports the *total* relief (both relief-at-source + higher-rate top-up combined) for the scenarios reported in #704. +- Added a plan for a reform impacts dashboard (#806) at `docs/book/validation/reform-impacts-dashboard-plan.md`, tracking the regression fixtures in `reforms_config.yaml` over model and dataset versions. +- Documented how capital gains are modelled and imputed (#818, #817) at `docs/book/assumptions/capital-gains-distribution-plan.md`, including the Microcosm UK stages that now fit gains jointly to HMRC Table 3 and what is left to do. +- Add a planning docs page (`docs/book/assumptions/non-uk-benefit-receipt-plan.md`) on benefit spending that does not reach UK private households (payments abroad, institutions, fraud and error) and how calibration targets should exclude it. +- Add a planning docs page (`docs/book/policy/indirect-tax-effects-plan.md`) on letting indirect tax revenue respond to direct tax reforms through a consumption elasticity, from a single household-level factor to per-category elasticities and a link to `consumer_incident_tax_revenue_change`. +- Documented the Ubuntu vs macOS reproducibility issue (#1209) at `docs/book/validation/ubuntu-vs-macos.md`: the import-time uprating cause, how it was resolved, and the October 2026 check that the parameter tree is byte-identical across macOS and Linux. +- Add a planning docs page for firm-level VAT (`docs/book/programs/gov/hmrc/firm-vat.md`): a proposed `Firm` entity, the registration threshold and supply split, output, input and net VAT, the data needed for a synthetic firm panel, and open questions. +- Added an effective take-up analysis plan (docs/book/assumptions/takeup-analysis-plan.md) listing each programme's take-up flag, reference parameter and current handling, where the data builds' seed rates live, and how to measure drift from them. +- Added a child poverty validation page (docs/book/validation/child-poverty-tcl.md) comparing the two-child limit removal on Microcosm UK 2024-25 (about 463,000 children lifted after housing costs, £3.26bn in 2029-30) with the DWP, Budget 2025 (OBR-certified) and Resolution Foundation estimates. +- Added a UK pipeline alignment planning page (docs/book/assumptions/uk-pipeline-alignment-plan.md): the model-side take-up gap still open, and what Microcosm UK now covers (reported-anchored take-up, the SPI stage-2 fill). +- Documented the policyengine.py household-impact zero-return diagnosis (docs/book/validation/policyengine-py-household-impact.md); the UK model returns the expected values, re-checked on 2.123.7. +- Add a planning docs page (`docs/book/assumptions/mp30k-uk-switch-plan.md`) setting out the gates and rollback path for changing the default dataset. It was written for Microplex UK and now applies to its successor, Microcosm UK. + +### Fixed + +- Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band, and set the annual exempt amount against the gains where each pound saves most tax. +- - Northern Ireland Local Housing Allowance rates now follow new gov.dfc.LHA.freeze and gov.dfc.LHA.percentile parameters, so reforms to gov.dwp.LHA.freeze and gov.dwp.LHA.percentile change Great Britain rates only. + + +## [2.142.0] - 2026-10-21 + +### Added + +- - Add `defined_benefit_pension_share`, `defined_benefit_pension_contributions` and `defined_benefit_pension_input_amount`, so data can say which workplace pension contributions go to defined-benefit arrangements and supply their statutory pension input amount (Finance Act 2004 s. 234) for the annual allowance. Where the input amount is not supplied it is those contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, set at 1 from the accrual and contribution rates of the main public service schemes. + +### Changed + +- - Estimate unused annual allowance carried forward (Finance Act 2004 s. 228A) from 2020-21 where none is supplied, instead of nil: each of the five previous years' pension input is this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), with incomes and input deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). + +### Fixed + +- - Use one definition of relevant UK earnings (Finance Act 2004 s. 189(2)) for pension contribution relief, the annual allowance income tests and the pension deduction in adjusted total income: employment income after allowable expenses but before pension deductions, and trading profits after capital allowances and the trading allowance but before losses brought forward. Relief previously counted pay before expenses and trading profits before capital allowances and the trading allowance. +- - Uprate water and sewerage charges by country when a dataset is projected: England and Wales by Ofwat's average bills, Scotland by a new Scottish Water series (8.8% in 2024-25, 9.9% in 2025-26 and 8.67% in 2026-27, then the CPI + 2% cap in WICS's draft 2027-33 determination), and Northern Ireland, which has no domestic water charges, not at all. The charges were previously held flat to 2030 and then grown by the England and Wales series everywhere. +- - Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. The national living wage parameter ends at its April 2026 rate, so the threshold stays at £10,572 in later years until that parameter is projected. + + +## [2.141.12] - 2026-10-21 + +### Fixed + +- - Fixed the Carer's Allowance earnings-limit property test to count pence in the precision the model stores earnings in (float32), as the formula does, so a draw whose float32 arithmetic rounds across a penny boundary no longer fails at random. + + +## [2.141.11] - 2026-10-21 + +### Fixed + +- - Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze. + + +## [2.141.10] - 2026-10-21 + +### Changed + +- - Reuse Pension Credit earnings tax calculations when checking Universal Credit housing contribution exemptions. + +### Fixed + +- Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit (including working tax credit with a disability element) or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at net earnings. Employed earnings are net of the income tax they add; self-employed earnings, in Pension Credit income too, are net of the notional tax and contributions of the Computation of Earnings Regulations 1996 reg. 14. The Housing Benefit savings-credit-only route now deducts only the higher Housing Benefit disregards. + + +## [2.141.9] - 2026-10-21 + +### Fixed + +- Council Tax Reduction income now counts the applicant's and partner's industrial injuries disablement benefit, severe disablement allowance and incapacity benefit in every scheme (SI 2012/2885 Sch 1 para 16; SI 2012/2886 Sch paras 39 and 54; WSI 2013/3029 Sch 1 para 10 and Sch 6 para 17; SSI 2012/319 reg 27; SSI 2021/249 reg 57). + + +## [2.141.8] - 2026-10-21 + +### Changed + +- Clarify domestic and overseas carbon flows and household incidence, including imported emissions in the consumption footprint and the chart's 2019 DEFRA fallback intensities. +- Use public Core operations for UK parameter initialization and dated-cache clearing while preserving parameter and reform behavior. + +### Fixed + +- Updated recent residential SDLT purchase schedules and applied the inclusive first-time-buyer price maximum, retaining annual sampling and contract-protection limitations. +- - Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, retained actual supplied ordinary returns in the annual-allowance calculations, and preserved the childcare income tests in #2173. + + +## [2.141.7] - 2026-10-21 + +### Fixed + +- - Added the 2022–23 annual employee National Insurance schedule for explicitly declared full-year category A directors in one employment, preserving ordinary employees and other years. +- - Corrected the JSA, ESA and DLA program registry variable mappings, narrowed Benefit Cap's parameter prefix to its own subtree, removed Maternity Allowance's unrelated DWP parameter prefix, and added validation of all declared registry variables and parameter prefixes. +- - Reject data-axis aggregate-impact comparisons when either measurement omits its Python version or machine identity. + + +## [2.141.6] - 2026-10-20 + +### Fixed + +- - Uprate `water_and_sewerage_charges`, `housing_service_charges` and `pension_contributions_via_salary_sacrifice`, which declared an uprating index but were absent from `uprating_indices.yaml` and so stayed flat through 2030. Also uprate twelve columns added since with the same gap, each by the index it declares: `cash_isa`, `stocks_and_shares_isa`, `mortgage_debt`, `consumer_debt`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `owned_company_capital`, `owned_company_holding_value` and `owned_company_trade_assets` (per-capita GDP) and `self_employment_gross_receipts` and `owned_company_income_share` (per-capita mixed income), plus `pension_contributions_via_salary_sacrifice_pre_2015` (average earnings). Adds a code-health test asserting that every variable declaring `uprating` appears in that file under the matching index, so the gap fails at the point it is introduced. `rail_usage` is excluded because its declared index is a cumulative level rather than a year-on-year rate, and the three energy columns because the data build already prices them (#1868). Updates Ofwat water bill growth to the published 27% for 2025-26 (was the 26% forecast) and 5.4% for 2026-27 (was a 5.5% projection), and documents the sources and vintage of the water bill and population growth series. +- - Limited the ordinary income-tax loss-cap pension uplift above earnings to relief-at-source contributions. + + ## [2.141.5] - 2026-10-20 ### Fixed diff --git a/Makefile b/Makefile index 44b7ac8e29..eee5b42189 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test-yaml: # Every Python test, serially. To spread them over four cores add # PYTEST_ARGS="-n 4 --dist worksteal" (pytest-xdist); to run what one CI -# shard runs, add "--shard K/4" (policyengine_uk/tests/conftest.py). CI runs +# shard runs, add "--shard K/5" (policyengine_uk/tests/conftest.py). CI runs # these tests through .github/workflows/test-suite.yaml, in the locked # environment (uv sync --frozen --extra dev), and also checks each test's # outcome against .github/test_outcome_allowlist.toml. diff --git a/changelog.d/2014-local.fixed.md b/changelog.d/2014-local.fixed.md new file mode 100644 index 0000000000..29cf62eda1 --- /dev/null +++ b/changelog.d/2014-local.fixed.md @@ -0,0 +1 @@ +Charge non-dependant deductions in the English working-age local Council Tax Reduction schemes (Merton, Kingston upon Thames, Newham, Westminster, Oxford) as the national schemes do: for adults in the applicant's own benefit unit (the applicant identified as for the means test, so a grandmother who applies alone is charged for the couple in her benefit unit), separately for each adult in another family who is not its claimant or partner, and apportioned between jointly liable applicants. Band a claimant or partner on the couple's joint income and anyone else on their own, each on their own hours; exempt a non-dependant on Income Support, income-based JSA, income-related ESA or State Pension Credit only if the award is payable to them, and one on Universal Credit only if they have no earned income of their own; and deduct for each member of a Universal Credit couple in Westminster, as the Default Scheme does. In every scheme, national and local, someone who pays the householder for their own board or lodging, or is a joint tenant, is not a non-dependant even where the rest of their family is, except a member of the household head's own family who pays board, who is taken to be a close relative. diff --git a/docs/book/assumptions/benefit-minimum-ages.md b/docs/book/assumptions/benefit-minimum-ages.md new file mode 100644 index 0000000000..01b444e4aa --- /dev/null +++ b/docs/book/assumptions/benefit-minimum-ages.md @@ -0,0 +1,81 @@ +# Benefit minimum ages + +Computed Carer's Allowance, Carer Support Payment (CSP) and Disability Living +Allowance (DLA) mobility awards apply these minimum ages to each person's existing +annual `age` input: + +| Computed benefit | Minimum age | +| --- | ---: | +| Carer's Allowance | 16 | +| CSP | 16 | +| Higher DLA mobility component | 3 | +| Lower DLA mobility component | 5 | + +The Carer's Allowance condition appears in section 70(3) of the +[Great Britain Act](https://www.legislation.gov.uk/ukpga/1992/4/section/70) and its +[Northern Ireland counterpart](https://www.legislation.gov.uk/ukpga/1992/7/section/70). +CSP's condition is in +[regulation 4(1)](https://www.legislation.gov.uk/ssi/2023/302/regulation/4). +The DLA mobility ages and corresponding higher/lower routes appear in sections +73(1), (1A) and (11) of the +[Great Britain Act](https://www.legislation.gov.uk/ukpga/1992/4/section/73) and +[Northern Ireland Act](https://www.legislation.gov.uk/ukpga/1992/7/section/73). +Other qualifying conditions remain necessary. These gates do not implement a full +eligibility assessment or change education, caring, disability, duration, upper-age +or programme-replacement rules. + +## Reported inputs and amount overrides + +A positive `carers_allowance_reported` value or sufficient caring hours cannot +bypass the age condition in the computed CA/CSP pre-overlap amount. A DLA mobility +category similarly cannot bypass its category's age condition. The raw reported +amounts and categories remain stored inputs. + +Explicitly supplying a pre-overlap or final amount keeps the model's generic input +override behavior. For example, an explicit `carers_allowance` or `dla_m` amount +can override its formula. Such an override is not evidence of statutory +entitlement. Report-only positive carer cases also preserve an existing proxy; +reported receipt does not legally waive the qualifying-care requirements. + +DLA care has no categorical minimum-age floor. Its separate needs, duration and +child-comparison conditions still apply, as set out in +[section 72](https://www.legislation.gov.uk/ukpga/1992/4/section/72) and its +[Northern Ireland counterpart](https://www.legislation.gov.uk/ukpga/1992/7/section/72). +A care category for an infant assumes those conditions; it is not automatic +entitlement. DLA totals continue to add the care and mobility components. + +## Dates and the annual model + +The CA age parameter begins on 1 January 2015 as the model's existing coverage +anchor. This is not a claim that the legal age condition commenced in 2015. +CSP's raw age parameter starts on 19 November 2023, the commencement of regulation +4. The DLA mobility parameters start on 9 April 2001, as recorded in the Acts' +amendment annotations. These dates do not fill missing historical benefit rates +or expand the existing Scottish CA-to-CSP model switch from 2025. + +`age` is a Person/YEAR floating-point stock, with an existing default of 40 when +omitted. The gates compare it directly, without flooring or rounding fractional +ages. Awards retain the existing annual rate-times-52 convention. They do not +calculate exact birthdays, part-year awards or award weeks. In particular, this +does not newly implement CSP regulation 4(2)'s leap-day birthday rule. + +At the unchanged 2026 rates, an otherwise qualifying carer aged 16 receives +£4,495.40 before overlap; an under-16 computed award is zero. Higher and lower DLA +mobility amounts are respectively £4,160 and £1,575.60 annually once the relevant +age floor is met. These conditional examples do not estimate a population effect +or establish independently encoded parity. + +## Combined CSP conditions + +This change is stacked on the repaired [PR #2473](https://github.com/PolicyEngine/policyengine-uk/pull/2473). +Both the age condition and the earnings condition apply to computed CSP, including +the reported-Carer’s-Allowance input proxy. Natural-pay cases combine age15, +fractional ages below16, age16, the exact annual earnings limit and favorable +fractional-penny treatment. The shared reader retains merged [PR #2444](https://github.com/PolicyEngine/policyengine-uk/pull/2444) +NI-age deductions. Existing annual/award-week, PAYE and care-charge qualifications +remain in the [CSP earnings assumptions](carer-support-payment-earnings.md). + +Only after #2473 actually merges should this branch be retargeted and revalidated +on current main; a queue entry or passing checks do not establish that landing. +The shared-parameter change in open [PR #2518](https://github.com/PolicyEngine/policyengine-uk/pull/2518) +requires future CSP-reader/prose coordination; it is not copied or repaired here. diff --git a/docs/book/assumptions/capital-gains-distribution-plan.md b/docs/book/assumptions/capital-gains-distribution-plan.md new file mode 100644 index 0000000000..5e47427100 --- /dev/null +++ b/docs/book/assumptions/capital-gains-distribution-plan.md @@ -0,0 +1,133 @@ +# Capital gains distribution + +```{note} +**Planning page, updated October 2026.** Tracks +[#818](https://github.com/PolicyEngine/policyengine-uk/issues/818) +(model gains jointly across income groups) and +[#817](https://github.com/PolicyEngine/policyengine-uk/issues/817) +(don't impute gains to households with no wealth). The imputation lives +in the data build, not in this repo. Most of what this page proposed +in May 2026 has since been built in +[Microcosm UK](https://github.com/PolicyEngine/microcosm), which builds +the replacement for the Enhanced FRS. The certified UK default dataset is +still the Enhanced FRS from +[policyengine-uk-data](https://github.com/PolicyEngine/policyengine-uk-data) +(release 1.58.0). What remains is listed at the end of this page. +``` + +## How the model uses capital gains + +`capital_gains_before_response` is a person-level input, uprated by OBR +per-capita GDP growth. `capital_gains_tax` charges it at the main CGT +rates, with three components charged at their own rates (all added by +[#1861](https://github.com/PolicyEngine/policyengine-uk/pull/1861)): + +- `capital_gains_badr`: gains qualifying for Business Asset Disposal + Relief or Investors' Relief, at the relief rate up to the lifetime + limit; +- `capital_gains_residential_property`: gains on UK residential + property, at the residential rates; +- `capital_gains_carried_interest`: carried interest, at the carried + interest rates. + +Each is a component of the total, not an addition to it. + +Behavioural responses: `capital_gains_behavioural_response` changes +realisations using an elasticity with respect to the retention rate +(`relative_capital_gains_retention_rate_change`) or the marginal tax +rate. Gains use `gov.simulation.capital_gains_responses.elasticity`, +which is 0 by default, so there is no response unless a user sets it. +When `gov.simulation.capital_gains_responses.separate_badr_elasticity` +is true (false by default), gains qualifying for BADR respond at +`badr_elasticity` instead (1.4, the value the OBR used for these gains +in its January 2025 costing), through `capital_gains_badr_elasticity`. + +## How the gains are imputed + +### Enhanced FRS: one distribution per income band + +The Enhanced FRS (policyengine-uk-data, the certified default) fits a +spline to the Advani and Summers gains percentiles within each income +band and draws each household's gains from its band. The spline alone +cannot produce gains above about £2m, so policyengine-uk-data main (not +yet in a release) also stacks 30 donor households per HMRC +size-of-gain band and calibrates to HMRC counts and amounts of gains by +band. #818 pointed out the problems with the +band-by-band fit: + +- each band's upper tail was fitted on few observations, so the tails + were noisy and the joint distribution of income and gains had breaks + at band boundaries; +- two people in the same band drew from the same distribution however + different their incomes; +- gains depended on income only, not on wealth, so they could be + imputed to households with no assets (#817). + +### Microcosm UK + +Microcosm builds gains in six stages (see the UK +[sources specification](https://github.com/PolicyEngine/microcosm/blob/main/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml)): + +1. **Support split** (`cgt_support_split`). Within each income band of + HMRC Capital Gains Tax statistics Table 3, the households with the + most investable wealth (financial wealth, business wealth and + property other than the main home) are split into lighter copies, so + that there are enough rows to hold the largest gains. +2. **Who has gains** (`cgt_incidence_clone`). The set and order of + gainers come from the incidence distribution in Advani and Summers + (2020), *Capital Gains and UK Inequality* (CAGE Working Paper 465). +3. **How much** (`hmrc_cgt_gains_spine`). Amounts are redrawn so that + gains match HMRC Table 3 for 2024-25, which counts taxpayers and + gains **jointly by size of gain and taxable income**, conditioned on + Tables 1, 2.1a, 5 and 6. +4. **Weight anchor** (`cgt_incidence_anchor`). Clones below the annual + exempt amount carry more weighted people than HMRC publishes, so the + excess weight is moved back to each clone's paired original. +5. **Residential property** (`cgt_residential_split`). Gains on UK + residential property are fitted to Table 8 (2024-25 residential + property disposals) by splitting households into weighted copies, + which fills `capital_gains_residential_property`. +6. **What kind** (`hmrc_cgt_asset_type_spine`). Of the remaining gains, + those qualifying for BADR or Investors' Relief are fitted to Table + 4.1, which fills `capital_gains_badr`, and the rest are given asset + types from Table 7 (gains by asset type, 2023-24). + +All the HMRC tables come from the Capital Gains Tax statistics July +2026 release, through Microcosm's pinned Chronicle feed. + +Against #818: gains are now fitted to the published joint distribution +of gain size and taxable income, not band by band. Against #817: +the largest gains are placed on the wealthiest households in each +income band. Microcosm's release checks also include a projection check +on people just below the annual exempt amount +(`uk_cgt_projection_entrants` in `gates.json`), so that +uprating does not push implausible numbers of them into paying CGT in +later years. + +## What is left + +- **A zero-wealth test.** Add a test, here or in Microcosm, that no + positive gains sit on households with zero investable wealth. Neither + repo asserts this directly yet, so #817 should stay open until it + does. +- **Close #818** once a Microcosm UK dataset is the certified default + and its diagnostics confirm the Table 3 fit; link them from the issue. +- **Elasticities by group.** Responses use one main elasticity and one + BADR elasticity. With the joint distribution in place, an elasticity + that varies by income or size of gain becomes possible, at the cost + of more parameters to justify. +- **Wealth source.** Investable wealth comes from Microcosm's Wealth + and Assets Survey imputation. Its quality limits how well #817 can be + met. + +The quantile regression forest approach proposed here in May is no +longer needed for this. + +## References + +- Issues: [#818](https://github.com/PolicyEngine/policyengine-uk/issues/818), [#817](https://github.com/PolicyEngine/policyengine-uk/issues/817). +- CGT components: [#1861](https://github.com/PolicyEngine/policyengine-uk/pull/1861). +- HMRC, [Capital Gains Tax statistics](https://www.gov.uk/government/statistics/capital-gains-tax-statistics). +- Advani, A. and Summers, A. (2020), *Capital Gains and UK Inequality*, CAGE Working Paper 465. +- Microcosm UK [sources specification](https://github.com/PolicyEngine/microcosm/blob/main/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml) (stages `cgt_support_split`, `cgt_incidence_clone`, `hmrc_cgt_gains_spine`, `cgt_incidence_anchor`, `cgt_residential_split`, `hmrc_cgt_asset_type_spine`). +- OBR (2025), [Costing of changes to the main, BADR and IR rates of capital gains tax](https://obr.uk/docs/dlm_uploads/CGT-supplementary-release-Jan-2025.pdf). diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md new file mode 100644 index 0000000000..eeaa6948e0 --- /dev/null +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -0,0 +1,77 @@ +# Carer Support Payment earnings + +Carer Support Payment (CSP) has an earnings limit. Regulation 14 of the +[Carer's Assistance Regulations 2023](https://www.legislation.gov.uk/ssi/2023/302/regulation/14) +excludes a carer when the earnings counted for the relevant award week exceed +the limit. A reported Carer's Allowance award does not exempt them. + +## Limits and penny fractions + +CSP has its own Sunday effective dates, separate from Carer's Allowance: + +| Effective date | Weekly limit | +| --- | ---: | +| 19 November 2023 | £139 | +| 7 April 2024 | £151 | +| 6 April 2025 | £196 | +| 5 April 2026 | £204 | + +The limit is inclusive. [Schedule 2 paragraph 4](https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4) +handles a fractional penny in the individual's favour. At the final earnings +test, £204.005 a week counts as £204.00 and passes; £204.01 exceeds the £204 +limit. The model compares annual earnings with the first excluded weekly +penny amount multiplied by 52, in the earnings array's numeric precision. +This applies the penny rule without an extra tolerance or rounding annual +earnings before converting them to a weekly amount. + +## Annual earnings approximation + +The model reuses `carers_allowance_earnings`, a Person-level annual amount in +pounds. It deducts modelled employment tax and primary Class 1 contributions, +notional tax and contributions on self-employment profits, and half the +individual's pension contributions. Partner and cared-for-person earnings do +not enter the carer's test. Employer pension contributions are not deducted. + +Annual net earnings are spread evenly over 52 weeks. Constant-through-year +inputs match the weekly comparison; varying earnings may not. Originally, +regulation 14 assessed that award week's earnings. From 16 November 2025 it +assesses the immediately preceding award week. Annual inputs cannot distinguish +those weeks or reproduce all pay-period and averaging cases in schedule 2. + +The shared reader approximates employment tax using PAYE on pay alone with a +standard code, rather than actual withholding. It does not represent all +statutory care-charge deductions and disregards. Reusing it does not certify +that every schedule 2 calculation is implemented. + +## Coverage and other conditions + +Raw parameter dates retain the statutory dates above. The existing annual +parameter conversion samples 30 April for a named fiscal year; the model's +2026 value therefore represents the £204 limit, rather than a claim that it +legally began on 1 January. CSP remains modelled from 2025, with the previous +Carer's Allowance route retained for Scotland in earlier model years. This +change does not extend coverage back to the 2023 pilot. + +The existing caring-hours, reported-receipt and take-up conditions remain. +Age, education, the cared-for person's qualifying-benefit conditions and the +full claim-status rules are separate gaps. Earnings above the limit remove +underlying CSP entitlement before the overlapping-benefit calculation. This +differs from an overlapping benefit reducing an otherwise entitled person's +payment to zero. The Scottish Carer Supplement is paid only when CSP is +payable. Caring hours and reported caring can still qualify a person for the +Universal Credit carer element, which does not apply this earnings limit. + +## Integration dependencies + +The shared Carer’s Allowance earnings reader retains the age-conditioned Class 2 +and Class 4 deductions from merged [PR #2444](https://github.com/PolicyEngine/policyengine-uk/pull/2444). +Natural trading-income cases cover exemption after State Pension age and Class 4 +liability in the tax year that pensionable age is reached. The existing annual +approximation remains. [PR #2474](https://github.com/PolicyEngine/policyengine-uk/pull/2474) +is the separate minimum-age change and must retain this earnings condition when +stacked or integrated. + +The open [PR #2518](https://github.com/PolicyEngine/policyengine-uk/pull/2518) +changes a shared Carer’s Allowance earnings parameter. Once this reader is shared +with CSP, that parameter’s effects on CSP require coordination and validation; +this page does not assume the two benefits remain independent of that parameter. diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md new file mode 100644 index 0000000000..39f9d64841 --- /dev/null +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -0,0 +1,104 @@ +# Switching the default dataset: gates and rollback + +```{note} +**Planning page.** [#1692](https://github.com/PolicyEngine/policyengine-uk/issues/1692) +asked for a switch and rollback path from the Enhanced FRS (eFRS) to Microplex UK +(`mp-30k-uk`). The `microplex-uk` repository has since been archived, and its +successor is [Microcosm UK](https://github.com/PolicyEngine/microcosm), whose +UK national line publishes `microcosm_uk_2024_25.h5`. This page applies the +same switch and rollback path to Microcosm UK. +``` + +## Current state + +PolicyEngine UK has no built-in default dataset. `Simulation()` uses, in order: + +1. an explicit `dataset=` argument; +2. the `POLICYENGINE_UK_DEFAULT_DATASET` environment variable + (`DEFAULT_DATASET_ENV_VAR` in `tax_benefit_system.py`); +3. otherwise, unless an inline `situation` is passed, it raises a `ValueError` + (`get_default_dataset_url` in `simulation.py`). + +The test suite sets the variable to the private +`hf://policyengine/policyengine-uk-data-private/enhanced_frs_2023_24.h5@1.40.3` +when a Hugging Face token is available (`policyengine_uk/tests/conftest.py`). +So the eFRS is still the dataset the model is tested against, and the +default in downstream tools such as policyengine.py. + +Direct microsimulation through `Microsimulation()` in this package is +deprecated in favour of [policyengine.py](https://github.com/PolicyEngine/policyengine.py), +whose managed bundle pins certified datasets (see the repository README). The +default most users meet is therefore the one that bundle pins, so a switch has +to be made there as well as in this repository's tests. + +## What the switch needs + +### Loader + +- `UKMultiYearDataset` and `UKSingleYearDataset` + (`policyengine_uk/data/dataset_schema.py`) are the containers both datasets + load into. A Microcosm file must use the same table and column conventions, + because `_pre_encode_enum_columns` (in `simulation.py`) walks each year's + tables. +- The dataset's `years` drive uprating. Microcosm's base year is 2024-25, + one year later than the eFRS file used in tests, so uprating paths from the + earlier base year need checking. +- Enum columns must use the labels in this repo's variable definitions. + Unknown labels do not encode correctly. + +### Variables + +- `policyengine_uk/variables/input/` is the data contract. A switch should + report the input columns present in one dataset but not the other, and + decide for each whether a missing column is acceptable. +- Take-up and other reported-versus-simulated inputs have to come from the + new data, not be carried over from the eFRS. + +### Calibration + +- Microcosm calibrates its own weights against its target register, and + certifies each release through its own checks. The model side should + compare results against those targets, not re-weight. + +## Switch criteria + +A documented gate must pass before any environment's default changes: + +1. **Aggregates:** headline totals (income tax, NICs, UC, Child Benefit, + State Pension) on both datasets for the same year, each set against OBR + outturn, with any difference of more than £1bn or 2% (whichever is wider) + explained. +2. **Distribution:** decile mean net income and HBAI poverty rates (before + and after housing costs) within 2 percentage points, or explained. +3. **Reforms:** at least three standard reforms (for example +1p on the basic + rate, +£5 a week on the UC standard allowance, abolishing the two-child + limit) give the same sign and are within 5% of each other. +4. **Tests:** the suite passes with the new default, including + `test_latest_data_smoke.py`, `test_deterministic_variables.py` and + `test_no_economic_assumptions.py`. +5. **Documentation:** the `assumptions/` section records the switch and any + remaining aggregate gaps. + +## Rollback path + +1. Dataset choice stays a runtime setting: no code in this repo depends on + which dataset is loaded. +2. While both are in use, CI runs the dataset-dependent tests on both. +3. The default is set per environment through + `POLICYENGINE_UK_DEFAULT_DATASET`, with the eFRS file kept available. +4. Rolling back is changing that variable and redeploying, with no code + change here. This assumes the eFRS file stays published while the switch + beds in. + +## Acceptance + +PolicyEngine UK switches its default only after the gates above pass, with a +rollback that needs no code change in this repo. Until then either dataset can +be used by passing `dataset=` or setting the environment variable. + +## References + +- Tracking issue: [#1692](https://github.com/PolicyEngine/policyengine-uk/issues/1692) (still open). Its umbrella discussion is in the archived [`microplex-uk`](https://github.com/PolicyEngine/microplex-uk/discussions/2) repository. +- Related: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621) (aligning the UK model and data pipeline). +- Microcosm UK: [repository](https://github.com/PolicyEngine/microcosm). +- Dataset loading: `policyengine_uk/simulation.py`, `policyengine_uk/data/dataset_schema.py`. diff --git a/docs/book/assumptions/non-uk-benefit-receipt-plan.md b/docs/book/assumptions/non-uk-benefit-receipt-plan.md new file mode 100644 index 0000000000..928177417f --- /dev/null +++ b/docs/book/assumptions/non-uk-benefit-receipt-plan.md @@ -0,0 +1,130 @@ +# Benefit expenditure paid outside UK private households (planned) + +```{note} +**Planning page.** The data behind PolicyEngine UK is calibrated so that +household benefit totals match published **DWP/HMRC/OBR totals**. +Some of that outturn doesn't go to **UK private households**. It +goes to people living abroad (exported pensions), people in +institutional accommodation outside the FRS sampling frame (care homes, +hostels), and a small administrative-leakage residual. This page +sets out the proposed treatment, tracked in +[#842](https://github.com/PolicyEngine/policyengine-uk/issues/842) (still +open, October 2026). Calibration targets are now set in +[Microcosm UK](https://github.com/PolicyEngine/microcosm), so most of the +work below belongs there. +``` + +## Why this matters + +Calibrating FRS household weights so that simulated `state_pension` +matches a national spending total assumes every pound of that total lands +on a household the FRS samples. It doesn't: + +- **State Pension paid abroad**: pensioners living overseas receive the + State Pension they built up. DWP's benefit expenditure tables report + the amount paid to recipients abroad, and Stat-Xplore gives the + caseload by country. None of these people are in the FRS. +- **Disability Living Allowance and PIP paid abroad**: limited cases + under reciprocal agreements (EU and EFTA countries and a handful of + others). +- **Institutional residents**: care-home residents receive most + benefits but the FRS doesn't sample care homes. Their entitlements + feed into the DWP outturn but not into PolicyEngine's `state_pension` + totals. +- **Administrative leakage**: fraud, error and advance payments that + appear in DWP cashflow but never reach an entitled person. + +Where a target includes these groups, calibration pushes their spending +onto FRS households and **overstates** what UK private households receive. + +**Already done for State Pension.** Microcosm no longer binds the OBR State +Pension line. State Pension is now bound to resident figures from DWP +Stat-Xplore (Great Britain) and the Northern Ireland Department for +Communities, and the OBR line and DWP's "paid outside UK" line are kept as +diagnostics. The plan is +[microcosm#1069](https://github.com/PolicyEngine/microcosm/issues/1069) and +the change landed in +[microcosm#1084](https://github.com/PolicyEngine/microcosm/pull/1084) +(merged 2 October 2026). The same question is open for other programmes. + +## Scope + +### Phase 1: quantify the gap by programme + +For each benefit programme the calibration targets, identify the share +of the published total that goes outside UK private households. DWP and HMRC +publish enough data to assemble a first-pass table: + +| Programme | UK private households | Overseas | Institutional | Admin / error | +|-----------|----------------------|----------|---------------|---------------| +| State Pension | published | DWP overseas tables | -- | -- | +| Pension Credit | published | small | minimal | minor | +| Universal Credit | published | minimal | small | F&E published | +| Housing Benefit | published | -- | care-home component | F&E published | +| Child Benefit | published | EU-treaty residue | -- | -- | +| Disability Living Allowance / PIP | published | reciprocal residue | -- | -- | +| Attendance Allowance | published | -- | care-home residue | -- | +| Winter Fuel Payment | published | overseas eligible cohort | -- | -- | + +Sources: DWP *Benefit expenditure and caseload tables* and Stat-Xplore +(payments abroad), the DWP *Fraud and Error in the Benefit System* annual +release, and programme-specific breakdowns. + +### Phase 2: subtract from calibration targets + +Once the gap is quantified per programme, each **calibration target** +should be the **UK-private-household part** of the total, not the full +total. Microcosm's State Pension change does this by binding a resident +series instead. Where no resident series exists, the target can carry an +explicit private-household share (0–1, default 1) with a cited source for +each override. + +This avoids the overweighting bias without trying to synthesise +out-of-scope households. + +### Phase 3: explicit out-of-scope correction (optional) + +For headline aggregates that need to match the **full** outturn (e.g. +fiscal cost of a reform), maintain an out-of-scope additive correction +per programme rather than synthesising fake households. This is +preferable because: + +- Distributional analyses already get the right answer at Phase 2 (UK + private households are correctly weighted to their own outturn). +- Synthesising overseas / institutional households would require strong + assumptions about their characteristics that aren't validated against + any micro-data source. + +The additive correction would live in the data pipeline and appear in +PolicyEngine UK as an explicit non-household spending line. + +## Implementation outline + +This is mainly a **data-side** change in Microcosm UK's target +register. (`policyengine_uk/programs.yaml` is metadata on model coverage, +not calibration targets, so it is not the place for these shares.) The +changes in this repo are small: + +- a non-household spending line for the Phase 3 correction, if adopted; +- documentation linking the affected benefit variables to this page. + +## Open questions + +- Should the `private_household_share` be year-varying (e.g. overseas + State Pension share rose post-EU exit) or held flat? +- For Universal Credit, where the FRS *does* sample some + institutionalised people (in supported accommodation), what's the + correct private-household-share value: strictly < 1, or 1 minus a + smaller residual? +- For the household-calculator (single-household) interface, the + private-household-share correction shouldn't apply, because the user + represents their own household. Confirm the correction is only on the + microsim weighted aggregates. + +## References + +- DWP, [Benefit expenditure and caseload tables](https://www.gov.uk/government/collections/benefit-expenditure-and-caseload-tables): spending by benefit, including payments abroad. +- DWP, [Fraud and Error in the Benefit System](https://www.gov.uk/government/collections/fraud-and-error-in-the-benefit-system): administrative leakage estimates. +- DWP, [Stat-Xplore](https://stat-xplore.dwp.gov.uk/): caseloads by benefit, including State Pension recipients by country of residence. +- HMRC, [Tax credits and Child Benefit statistics](https://www.gov.uk/government/collections/personal-tax-credits-statistics): EU-treaty Child Benefit residue. +- Issue: [#842](https://github.com/PolicyEngine/policyengine-uk/issues/842). Related: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621) (UK pipeline alignment) and [microcosm#1069](https://github.com/PolicyEngine/microcosm/issues/1069) (State Pension on resident figures). diff --git a/docs/book/assumptions/takeup-analysis-plan.md b/docs/book/assumptions/takeup-analysis-plan.md new file mode 100644 index 0000000000..64cd8e5679 --- /dev/null +++ b/docs/book/assumptions/takeup-analysis-plan.md @@ -0,0 +1,151 @@ +# Effective take-up rate analysis plan + +```{note} +**Planning page.** Tracks +[#1354](https://github.com/PolicyEngine/policyengine-uk/issues/1354), +which asks for a comprehensive analysis of how the data processing +pipeline (reweighting, SPI integration) changes effective take-up +rates away from the seed values. This page documents the methodology +and per-programme target list. +``` + +```{admonition} Status, October 2026 +The analysis below has not been run yet. Take-up flags are drawn in the +data build, not in this repository. The certified UK default dataset is +still the Enhanced FRS built by +[`policyengine-uk-data`](https://github.com/PolicyEngine/policyengine-uk-data) +(release 1.58.0). [Microcosm UK](https://github.com/PolicyEngine/microcosm) +builds the replacement national dataset with the same take-up design. +Both draw Universal Credit, Pension Credit and Child Benefit take-up +anchored on reported receipt, so the analysis should run on whichever +dataset is the certified default at the time. +``` + +## Why this matters + +PolicyEngine UK does not compute benefit take-up from scratch. The data +build **seeds** stochastic take-up flags (`would_claim_*`) per programme +from published target rates, then runs them through: + +1. **Reported anchoring.** Universal Credit, Pension Credit and Child + Benefit flags are inputs, drawn so that units reporting receipt + claim. Housing Benefit, Income Support, the tax credits and Council + Tax Reduction instead derive their flags at runtime from reported + receipt (or `claims_all_entitled_benefits`); see + [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +2. **Reweighting** to match aggregate caseload and expenditure + targets. +3. **SPI integration** for income-tax-relevant variables (which can + shift the income distribution and therefore who falls into each + benefit's eligible population). + +By the end of the pipeline the *effective* take-up rate, the share of +the eligible population that ends up with `would_claim_X == True`, can +diverge from the seed rate by a few percentage points. The seeded rates +themselves were calibrated against pre-reweighted aggregates, so the +real interest is how the post-pipeline shares stack up against +published outturn share figures. + +## Where the seed rates live + +The take-up rate parameters in this repository (for example +`gov.dwp.universal_credit.takeup_rate` and `gov.dwp.housing_benefit.takeup`) +are reference values: no formula reads them. The rates the data build +draws from are in `policyengine_uk_data/parameters/take_up/` in +policyengine-uk-data and in `build/uk/take_up_contract.json` in +Microcosm. The analysis should read the seed rate from the data build +that produced the dataset, and check the matching parameter here is in +sync. + +## Programmes to cover + +Per #1354, with current PolicyEngine UK take-up handling: + +| Programme | Variable | Take-up flag | Reference parameter here | Handling | +|-----------|----------|--------------|--------------------------|----------| +| Universal Credit | `universal_credit` | `would_claim_uc` (input) | `gov.dwp.universal_credit.takeup_rate` | Drawn in the data build, anchored on reported receipt | +| UC childcare element | `uc_childcare_element_claimed` | `would_claim_uc_childcare` (input) | – | Drawn in Microcosm at single and couple rates; the Enhanced FRS does not set it, so it defaults to true | +| Pension Credit | `pension_credit` | `would_claim_pc` (input) | `gov.dwp.pension_credit.takeup` | Drawn in the data build, anchored on reported receipt | +| Child Benefit | `child_benefit` | `would_claim_child_benefit` (input) | `gov.hmrc.child_benefit.takeup.overall` and `.by_age` | Drawn in the data build, anchored on reported receipt | +| Housing Benefit | `housing_benefit` | `would_claim_housing_benefit` (formula) | `gov.dwp.housing_benefit.takeup` | Follows reported receipt (#1621) | +| Income Support | `income_support` | `would_claim_IS` (formula) | `gov.dwp.income_support.takeup` | Follows reported receipt (#1621) | +| Child Tax Credit | `child_tax_credit` | `would_claim_CTC` (formula) | `gov.dwp.tax_credits.child_tax_credit.takeup` | Follows reported receipt; scheme ended 2025-04-06 | +| Working Tax Credit | `working_tax_credit` | `would_claim_WTC` (formula) | `gov.dwp.tax_credits.working_tax_credit.takeup` | Follows reported receipt; scheme ended 2025-04-06 | +| Council Tax Reduction | `council_tax_reduction` | `would_claim_council_tax_reduction` (formula) | – | Local schemes simulated; take-up follows reported receipt (#1669 open) | +| Tax-Free Childcare | `tax_free_childcare` | `would_claim_tfc` (input) | – | Drawn in the data build at a flat rate | +| Universal childcare entitlement | `universal_childcare_entitlement` | `would_claim_universal_childcare` (input) | – | Drawn in the data build at a flat rate | +| Extended childcare entitlement | `extended_childcare_entitlement` | `would_claim_extended_childcare` (input) | – | Drawn in the data build at a flat rate | +| Targeted childcare entitlement | `targeted_childcare_entitlement` | `would_claim_targeted_childcare` (input) | – | Drawn in the data build at a flat rate | +| Marriage Allowance | `marriage_allowance` | `would_claim_marriage_allowance` (input) | `gov.hmrc.income_tax.allowances.marriage_allowance.takeup_rate` (0.5, the post-2019 steady state) | Drawn in the data build at a flat rate | +| Scottish Child Payment | `scottish_child_payment` | `would_claim_scp` (input) | `gov.social_security_scotland.scottish_child_payment.takeup_rate` (under 6, 6 and over) | Drawn in the data build by age | +| Carer's Allowance | `carers_allowance` | `would_claim_carers_allowance` (input) | – | Set from reported receipt in Microcosm; the Enhanced FRS does not set it, so it defaults to true | +| 16 to 19 Bursary Fund | `bursary_fund_16_to_19` | `would_claim_bursary_fund_16_to_19` (input) | – | Defaults to true | +| Student support grants | `adult_dependants_grant`, `childcare_grant`, `parents_learning_allowance`, `travel_grant`, `disabled_students_allowance` | `would_claim_adult_dependants_grant`, `would_claim_childcare_grant`, `would_claim_parents_learning_allowance`, `would_claim_travel_grant`, `would_claim_disabled_students_allowance` (inputs) | – | Default to true | +| Care to Learn | `care_to_learn` | `would_claim_care_to_learn` (input) | – | Defaults to true | +| State Pension | `state_pension` | n/a (effectively universal) | – | See [state-pension.md](../programs/gov/dwp/state-pension.md) | +| PIP / DLA / AA / SDA | various | none | – | Category inputs set from reported receipt; see [disability-legacy-benefits.md](../programs/gov/dwp/disability-legacy-benefits.md) | + +## Methodology + +For each programme above, the analysis should produce: + +1. **Seed take-up rate:** the rate the data build drew from for the + analysis year, plus the source (HMRC, DWP or an academic study) and + vintage. +2. **Effective take-up rate post-pipeline:** counted from the built + dataset as + `sum(would_claim_X * eligible_X * weight) / sum(eligible_X * weight)`, + where `eligible_X` is computed from the runtime formulas. The + numerator counts only eligible units, because `would_claim_X` can be + true for ineligible units too (otherwise one eligible unit and two + willing units would give 200%). Where no unit is eligible the + denominator is zero; report the rate as undefined (n/a), not 0%. +3. **Published outturn share** for the same year: DWP and HMRC + statistical publications giving claimant counts and either eligible + population counts (where DWP estimates them) or a published take-up + rate. +4. **Pipeline drift:** `effective − seed` per programme, with a sign + convention so positive means more claimants than seeded. + +The published reference rates change roughly annually. The analysis +should be repeatable, ideally as a notebook under +[`docs/book/validation/`](../validation/) so future builds can re-run +it and update the gaps table. + +## Output + +A new validation notebook +`docs/book/validation/take-up-rates.ipynb` should: + +- pull each programme's seed take-up parameter, +- compute the effective rate against the built dataset, +- compare to the most recent published share (DWP take-up tables for + income-related benefits, HMRC Tax-Free Childcare and tax credits + statistics for the rest), +- flag drifts of more than ±2 percentage points for follow-up. + +The first run is the deliverable for #1354. After that the notebook +becomes a part of the validation page and gets re-run on each EFO / +data refresh. + +## Related work + +- **Reported-anchored take-up** is in both data builds for UC, Pension + Credit and Child Benefit. The programmes whose flags still follow + reported receipt at runtime are the most likely source of drift + between seed and effective rates. +- The **`would_claim_*` input-only conversion** (also in #1621) would + give the analysis a cleaner ladder: with all of `would_claim_*` as + inputs, the effective rate is exactly the share assigned by the + data build's take-up stage, and pipeline drift becomes a pure + reweighting question. + +## References + +- Issue: [#1354](https://github.com/PolicyEngine/policyengine-uk/issues/1354). +- Initial analysis (UC and CTC): +- DWP, [Income-related benefits: estimates of take-up](https://www.gov.uk/government/collections/income-related-benefits-estimates-of-take-up): the main reference rates for Housing Benefit, Income Support, Pension Credit and Universal Credit. +- HMRC, [Personal tax credits statistics](https://www.gov.uk/government/collections/personal-tax-credits-statistics). +- HMRC, [Child Benefit statistics](https://www.gov.uk/government/collections/child-benefit-statistics) and Marriage Allowance statistics. +- Pipeline alignment: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +- Cross-links: [`state-pension.md`](../programs/gov/dwp/state-pension.md), [`disability-legacy-benefits.md`](../programs/gov/dwp/disability-legacy-benefits.md), [`tax-credits.md`](../programs/gov/dwp/tax-credits.md). diff --git a/docs/book/assumptions/uk-pipeline-alignment-plan.md b/docs/book/assumptions/uk-pipeline-alignment-plan.md new file mode 100644 index 0000000000..34def30814 --- /dev/null +++ b/docs/book/assumptions/uk-pipeline-alignment-plan.md @@ -0,0 +1,173 @@ +# UK model/data pipeline alignment plan + +```{note} +**Planning page** setting out the gaps between the UK model and data +pipeline and the US `policyengine-us` / `policyengine-us-data` patterns, +plus a sequence of scoped PRs that would close them. Tracked in +[#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +Most items here are **cross-repo**: they touch the data pipeline rather +than this repo, but the model-side contract is documented here so the +receiving side has a stable target. +``` + +```{admonition} Status, October 2026 +The target data pipeline is now +[Microcosm UK](https://github.com/PolicyEngine/microcosm), which replaced +the Enhanced FRS build in `policyengine-uk-data`; its UK national line is +built on the Family Resources Survey 2024-25 (`microcosm_uk_2024_25`). +Gaps 2 and 3 below, and PR 4 of the sequence, are implemented there; +gap 1 is still open on the model side. The aggregate residuals are the +May 2026 Enhanced FRS measurements and have not been re-measured on the +Microcosm release. +``` + +## Architectural gaps vs US + +### 1. `_reported` consulted at runtime in some `would_claim_*` formulas + +**US pattern**: `_reported` columns are consulted **once, in the +data-build**, to anchor stochastic take-up flags (reported recipient → +takeup = True with certainty; rest filled probabilistically to a target +rate). Runtime formulas only read the pre-computed +`takes_up_X_if_eligible` flag, so reforms that change eligibility flow +through cleanly to take-up. + +**UK pattern (partial)**: some UK `would_claim_*` variables already +match: `would_claim_uc` and `would_claim_pc` are input-only with +`default_value = True`, populated by the data build (now Microcosm +UK's `frs_take_up` stage). But several still derive from +`_reported` at runtime: + +- `would_claim_housing_benefit` +- `would_claim_IS` +- `would_claim_WTC` +- `would_claim_CTC` + +The runtime pattern is `claims_all_entitled_benefits | (foo_reported > 0)`, +which means a reform expanding eligibility only reaches existing +`_reported > 0` claimants. Non-claimants who newly qualify are +invisible unless the user manually sets +`claims_all_entitled_benefits = True` everywhere. + +**Fix**: convert each to input-only `default_value = True` (matching +`would_claim_uc`), and add each to Microcosm UK's reported-anchored +take-up stages. Tax credits stopped paying ongoing awards on 5 April +2025, and `gov.dwp.tax_credits.active` is false from 2025-04-06 (see +[tax-credits.md](../programs/gov/dwp/tax-credits.md)), so the WTC and CTC +fix matters only for simulations of earlier years. + +### 2. Take-up assignment ignores reported data + +**Done in Microcosm UK.** Its `frs_take_up`, `pension_credit_take_up` +and `child_benefit_take_up` stages keep every benefit unit that reports +receipt as a claimant and draw the rest to the target rate +([`source_stages.json` at `75167a6`](https://github.com/PolicyEngine/microcosm/blob/75167a68/packages/microcosm-build/src/microcosm/build/uk/source_stages.json)). +The description below is the Enhanced FRS behaviour this replaced. + +The Enhanced FRS take-up assignment was a pure random draw: + +```python +pe_benunit["would_claim_uc"] = generator.random(len(pe_benunit)) < universal_credit_rate +``` + +This discards information: respondents who reported receiving UC +clearly took it up, and should have `would_claim_uc = True` with +certainty. + +**Fix**: port `policyengine_us_data/utils/takeup.py::assign_takeup_with_reported_anchors` +into `policyengine-uk-data` and apply it to each take-up variable where +a `_reported` column exists. This tightens per-variable calibration +without changing the target rates. + +### 3. Second-stage imputation gap on SPI donors + +**Done in Microcosm UK.** `hmrc_spi_income_spine` trains an FRS-only +second stage on the new SPI incomes (its outputs include `gift_aid` and +`charitable_investment_gifts`), `spi_housing_shell` redraws tenure, +dwelling and council tax band for the SPI households, and the wealth, +spending and VAT stages now run after the SPI block, so they are drawn +for the new incomes rather than inherited from the FRS parent. The +description below is the Enhanced FRS behaviour this replaced. + +`policyengine_uk_data/datasets/imputations/income.py::impute_income` +trains a QRF that replaces only six income variables on SPI donor +rows; everything else (rent, wealth, pension contributions, gift aid, +benefits-reported flags) stays as whatever middle-income FRS donor +happened to be sampled. So a synthetic SPI donor ending up with £2M +imputed self-employment income still carries an unchanged middle-FRS- +donor's savings balance and zero gift aid. + +The US equivalent ([`policyengine-us-data#589`](https://github.com/PolicyEngine/policyengine-us-data/pull/589)) +fixed this by adding a second-stage QRF: train on CPS with predictors = +demographics + newly-imputed income vars, outputs = the ~60 CPS-only +variables; for PUF-clone prediction, substitute imputed PUF incomes as +predictors so CPS-only variables come out consistent with imputed +income. + +**Fix**: add the analogous second-stage QRF in +`policyengine_uk_data/datasets/imputations/`, training on FRS with +predictors = demographics + 6 income vars, outputs = a wide set of +"FRS-only" consequential variables including rent, +`mortgage_interest_repayment`, `gift_aid`, `covenanted_payments`, +`charitable_investment_gifts`, `other_deductions`, +`employee_pension_contributions*`, `employer_pension_contributions`, +total wealth components, `capital_allowances`, `deficiency_relief`, +and the full list of `*_reported` benefits. + +## Specific aggregate residuals + +After the formula-side fixes already merged (BASIC/NEW classification +in [PR #1618](https://github.com/PolicyEngine/policyengine-uk/pull/1618), +new State Pension pro-rating + Protected Payment in +[PR #1634](https://github.com/PolicyEngine/policyengine-uk/pull/1634)), +the residual benefit-aggregate gaps against OBR on the Enhanced FRS +(measured May 2026, except the State Pension row, which is taken from +the State Pension page; none re-measured on Microcosm UK) were: + +| Variable | Model | Target | Gap | Likely cause | +|----------|------:|------:|----:|--------------| +| `income_support` | ~£1.07 bn | ~£0.2 bn | **+£0.9 bn** | Legacy benefit near-fully migrated; same `reported > 0` retention issue as the (pre-fix) tax credits. | +| `esa_contrib` | ~£5.56 bn | ~£2.5 bn | **+£3.1 bn** | Large. Likely reported-based take-up plus no phase-out for migrated cases. | +| `attendance_allowance` | ~£8.76 bn | ~£7.0 bn | +£1.8 bn | Cap-rate or take-up issue. | +| `tax_free_childcare` | ~£0.41 bn | ~£0.9 bn | **-£0.5 bn** | Understated by about half: under-imputed eligible families. | +| `state_pension` | £119.0 bn | £131.3 bn | -£12.2 bn | 2024-25, against DWP's Spring Forecast 2026 less payments abroad, on policyengine-uk-data-private 1.57.4, as documented in [state-pension.md](../programs/gov/dwp/state-pension.md). The shortfall arises in building the Enhanced FRS ([policyengine-uk-data#493](https://github.com/PolicyEngine/policyengine-uk-data/issues/493)). | + +Each warrants a scoped PR pair (model + data) once the architectural +fixes above land. + +## Items already fixed since #1621 was filed + +The issue body flagged three small bugs; all are now resolved: + +- `tax_credits/min_benefit.yaml` unit was `currency-USD`; it is now `currency-GBP`. +- `benunit_weekly_hours` label said "Average" but the formula sums; label now reads "Total weekly hours worked by adults in the benefit unit". +- `state_pension_type` BASIC/NEW classification (the `is_SP_age` heuristic in the issue) was rewritten in PR #1618. + +This page reflects the *current* gap rather than the issue body, so the +next reviewer working through the queue doesn't waste time on +already-fixed items. + +## Candidate PR sequence + +1. **Convert remaining `would_claim_*` formulas to input-only** (model + side) + reported-anchored assignment in Microcosm UK's `frs_take_up` + stage. Still open: the four formulas above read `_reported` at + runtime on `main`. +2. ~~Port `assign_takeup_with_reported_anchors`~~: done in Microcosm UK + (gap 2). +3. ~~Add second-stage QRF for FRS-only variables on SPI donors~~: done + in Microcosm UK (gap 3). +4. ~~Add `gift_aid` to `IMPUTATIONS`~~: done in Microcosm UK, where + `gift_aid` is a stage-2 output. +5. **Residual benefit-aggregate follow-ups**: re-measure on the + Microcosm release first, then separate small PRs for + IS phase-out (analogous to the WTC/CTC reactive scheme close-out), + ESA contrib investigation, AA calibration, TFC under-imputation, + the State Pension data-side fix (#1632, policyengine-uk-data#493). + +## References + +- Tracking issue: [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +- US pattern reference: [`policyengine-us-data#589`](https://github.com/PolicyEngine/policyengine-us-data/pull/589). +- Related state pension docs: [state-pension.md](../programs/gov/dwp/state-pension.md), [#1632](https://github.com/PolicyEngine/policyengine-uk/issues/1632). +- Related calibration scope: [non-uk-benefit-receipt-plan.md](./non-uk-benefit-receipt-plan.md) (#842), separate from this pipeline alignment but interacting with it on the calibration target side. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index 990944a734..a97a5a6df4 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -65,15 +65,44 @@ We store water bills year-on-year growth rates in: ### Growth rates -The model uses these resulting year-on-year growth rates: - -| Year | Growth rate | -|------|-------------| -| 2022 | 5.2% | -| 2023 | 9.2% | -| 2024 | 4.4% | -| 2025 | 6.1% | -| 2026 | 6.1% | -| 2027 | 5.1% | -| 2028 | 3.8% | -| 2029 | 4.3% | +The model uses these year-on-year growth rates. Each year is the charging year starting in April, so 2025 is 2025-26. + +| Year | Growth rate | Source | +|------|-------------|--------| +| 2022 | 5.2% | Ofwat average bills, real bills converted to nominal with CPIH | +| 2023 | 9.2% | As above | +| 2024 | 4.4% | As above | +| 2025 | 27% | [Ofwat statement, 29 January 2026](https://www.ofwat.gov.uk/ofwat-responds-to-water-sector-average-bill-figures/) (later figure; Ofwat's [30 January 2025 statement](https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/) forecast 26%) | +| 2026 | 5.4% | [Water UK, 28 January 2026](https://www.water.org.uk/news-views-publications/news/record-investment-average-household-water-bills-rise-ps33-year-april): national average £639, up £33 | +| 2027 | 4.6% | Projection from `forecast_water_bills.py` | +| 2028 | 4.5% | As above | +| 2029 | 4.5% | As above | + +```{warning} +The 2027-2029 projections cannot be reproduced from the repository. They take the CCW table, which reflects Ofwat's PR24 *draft* determinations rather than the final ones, weight each company by customer numbers, and add a hardcoded CPIH path. The customer weights were in `ofwat_increases.csv`, which is gitignored and was never committed. +``` + +## Scotland and Northern Ireland + +Ofwat's bills cover England and Wales. When a dataset is projected forward, each household's `water_and_sewerage_charges` grows by its own country's series (`uprate_water_and_sewerage_charges` in `policyengine_uk/data/economic_assumptions.py`): + +- England and Wales, and households with an unknown region, take `ofwat.water_bills`. +- Scotland takes `scottish_water.water_bills`. Scottish Water sets household charges within limits from the [Water Industry Commission for Scotland](https://wics.scot/) (WICS), and every council tax band's charge is a fixed fraction of Band D, so each band rises by the same percentage. +- Northern Ireland has no domestic water charges, so any charge a dataset gives its households is held flat. + +This holds to the end of the dataset projection (2032). After it, policyengine-core grows every household's charge by the index the variable declares, Ofwat's England and Wales series, as it did before this split. + +| Year | Scottish Water growth | Source | +|------|-------------|--------| +| 2022 | 4.2% | [WICS, charges scheme 2022-23](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23) | +| 2023 | 5.0% | [WICS, charges scheme 2023-24](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24) | +| 2024 | 8.8% | [WICS, charges scheme 2024-25](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25) | +| 2025 | 9.9% | [Scottish Water, 28 January 2025](https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement) | +| 2026 | 8.67% | [Scottish Water, 22 January 2026](https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026) | +| 2027 | 3.9% | [WICS Draft Determination 2027-33](https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf): the CPI + 2% core cap (para. 9.1.3) with WICS's CPI forecast of 1.9% (Table 68) | +| 2028 | 3.9% | As above | +| 2029 | 4.1% | As above, with CPI at 2.1% | + +Each year is the charging year starting in April, so 2025 is 2025-26. Later years to 2032 hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. + +`policyengine_uk/utils/create_ahc_deflator.py` still reads the England and Wales index. It is a standalone script that the model does not run. diff --git a/docs/book/index.md b/docs/book/index.md index b4949f3ee0..77e48adc15 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -67,3 +67,7 @@ revenue = ( ) f"Revenue: £{round(revenue / 1e9, 1)}bn" ``` + +## Model reference + +- [National Insurance parameter scales](programs/gov/hmrc/national-insurance-scales.md) diff --git a/docs/book/policy/future-date-announcements.md b/docs/book/policy/future-date-announcements.md new file mode 100644 index 0000000000..8e91ec8ce7 --- /dev/null +++ b/docs/book/policy/future-date-announcements.md @@ -0,0 +1,147 @@ +# Future-dating policy announcements + +PolicyEngine UK supports modelling policy changes that have been +**announced but not yet implemented**, or that take effect at a known +future date. The mechanism is the same one that handles legislated rate +schedules: parameters in `policyengine_uk/parameters/` carry a list of +values keyed by effective date. + +This was originally requested in [#636](https://github.com/PolicyEngine/policyengine-uk/issues/636) +in the context of the Truss "growth plan" announcements in autumn 2022. +The feature has been live and well-exercised since the model moved to +date-keyed parameter values; this page documents the current usage +pattern so that future analyses don't need to reinvent it. + +## How it works + +Every parameter is a `values:` map from effective date to value. When +the tax-benefit system is built, `convert_to_fiscal_year_parameters` +(`policyengine_uk/utils/parameters.py`) turns each parameter into one +value per model year, where model year 2026 is the fiscal year 6 April +2026 to 5 April 2027: + +- by default, the value in force on 30 April of that year; +- for parameters with `fiscal_year_blend: true` in their metadata (fuel + duty, for example), the day-weighted average over the fiscal year, so a + change part-way through the year counts for the share of the year it + applies; +- parameters with `preserve_calendar_dates: true` keep their statutory + dates, and their formulas annualise explicitly. + +Example from +[`gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml): + +```yaml +values: + ... + 2021-04-06: + value: 12_570 + ... + 2027-04-06: + value: 12_570 + ... + 2030-04-06: + value: 12_570 + metadata: + reference: + - title: Finance Act 2021 s. 5 (2), as amended by Finance Act 2026 s. 10 (3) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/10 +``` + +The 2030-04-06 row records a future-dated decision: the freeze to +2030-31, announced at the 2025 Budget and since enacted by Finance Act +2026 s. 10. The file has no uprating. From the tax year after its last +row, `policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py` +applies the Income Tax Act 2007 s. 57 CPI indexation year by year. So +model years up to 2030 return £12,570, and indexation starts in 2031. + +## Patterns for common cases + +### Announced but reversed before taking effect + +Some announcements are walked back before the effective date. The +Truss "growth plan" basic-rate cut to 19% (announced September 2022, +reversed October 2022) is the canonical example. To represent this +honestly: + +1. When the announcement is made, add the announced value at the + announced effective date with a reference to the announcement (for + the Truss case, 19% from 6 April 2023). +2. When it is reversed, remove that row, or replace its value with the + value that actually applies from that date (20% from 6 April 2023), + and cite the reversal announcement. A new row at the reversal date + does not cancel the future row: parameter rows apply from their own + dates, so a 20% row dated October 2022 is followed by the 19% row in + April 2023, and fiscal-year conversion still gives 19% for 2023. +3. Keep the announcement history in the row's references and in version + control rather than in a live value. + +The baseline then follows what was finally enacted. PolicyEngine UK has +no "policy as announced on date X" switch, so to score an announcement as +it stood before a reversal, apply it as a reform (see +the [excise duties page](../../engineering/excise-duties.md) for how +dated reforms are annualised). + +### Phased-in or staggered changes + +Phased rate changes are the most common future-dating pattern. Each +phase is a row at its effective date with a separate `reference`. The +`national_insurance/class_1/rates/employee/main.yaml` trajectory is a +good worked example: 13.25% from April 2022 under the Health and Social +Care Levy, back to 12% from November 2022 when the levy was repealed, +then the cuts to 10% in January 2024 and 8% in April 2024. + +### Announced abolitions + +For an abolition announced ahead of its effective date (for example the +2025 Budget removal of the two-child limit, in +[`gov/dwp/universal_credit/elements/child/limit/child_count.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/limit/child_count.yaml)): + +- The relevant parameter (`gov.dwp.universal_credit.elements.child.limit.child_count`) + has its limiting value (2) up to the announced effective date + (2026-04-06) and `.inf` from that date onwards. +- Model year 2025 (fiscal year 2025-26) returns 2, so the limit applies. +- From model year 2026 the value is `.inf`, so the limit no longer + restricts the UC child element. + +The same pattern applies in reverse to one-off or time-limited +payments. The means-tested Cost-of-Living Payment in +[`gov/treasury/cost_of_living_support/means_tested_households/amount.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/treasury/cost_of_living_support/means_tested_households/amount.yaml) +is £650 from 2022, £900 from 2023 and goes back to zero from +2024-01-01. + +## Best practices + +1. **Cite the announcement in `metadata.reference`** at every + future-dated row. Where possible, cite both the announcement and + the legislation that enacts it once it is published. The 2022-04-06 + row of `national_insurance/class_1/rates/employee/main.yaml`, for + example, cites both the Health and Social Care Levy Act 2021 and the + Budget document. +2. **Don't extrapolate**. Each future-dated row should reflect a + specific announcement. If a parameter would otherwise move with + uprating, let the uprating handle it. Adding a "frozen forever" row is + a policy assertion that needs its own reference. +3. **Check how the parameter is annualised.** A change dated part-way + through a fiscal year only counts for part of the year if the + parameter has `fiscal_year_blend: true`; otherwise the model year takes + the value in force on 30 April. + +## What this isn't + +This is not the same as **scenario analysis** or **policy reform** +parameters under `gov.contrib.*`. Those are reform-side toggles for +hypothetical changes the user wants to evaluate; future-dated parameter +rows under `gov.hmrc.*`, `gov.dwp.*` etc. are **baseline** policy +representing what is actually announced. + +If a future change is uncertain or contested, model it under +`gov.contrib.*` until it's announced; once announced, move it into the +baseline tree with an effective-date row. + +## References + +- Issue [#636](https://github.com/PolicyEngine/policyengine-uk/issues/636): the original request, from the Truss growth plan period. +- PolicyEngine research: [Tax cuts in Prime Minister Truss's growth plan](https://www.policyengine.org/uk/research/tax-cuts-in-prime-minister-trusss-growth-plan), a worked example of modelling an announcement before its reversal. +- [PolicyEngine Core](https://github.com/PolicyEngine/policyengine-core): the date-keyed parameter mechanism underneath. +- `convert_to_fiscal_year_parameters` in `policyengine_uk/utils/parameters.py`: how dated values become model-year values. diff --git a/docs/book/policy/indirect-tax-effects-plan.md b/docs/book/policy/indirect-tax-effects-plan.md new file mode 100644 index 0000000000..7c1fb0b296 --- /dev/null +++ b/docs/book/policy/indirect-tax-effects-plan.md @@ -0,0 +1,136 @@ +# Indirect tax effects of reforms (planned) + +```{note} +**Planning page.** PolicyEngine UK already routes ad-hoc indirect-tax +revenue changes through `consumer_incident_tax_revenue_change` at the +household level, but this is a **manual** lever the user has to set via +the `gov.contrib.policyengine.budget.consumer_incident_tax_change` +parameter. There is no automatic pass-through from changes in direct +taxes (e.g. income tax, NI) to consumption-mediated indirect taxes. +This page sets out a proposed automatic route, tracked in +[#1114](https://github.com/PolicyEngine/policyengine-uk/issues/1114) (still +open, October 2026). +``` + +## What this is about + +When a direct tax reform changes a household's disposable income, real +households respond by adjusting consumption. That changes the indirect +tax revenue (VAT, excise duties, fuel duty) the household generates. +Right now PolicyEngine UK computes: + +1. **Direct effect**: the full structural change to income tax, NI, UC + and so on under the reform. +2. **Behavioural response on labour supply**: modelled with the + substitution and income elasticities under + `gov.simulation.labour_supply_responses` (`policyengine_uk/dynamics/labour_supply.py`, + `employment_income_behavioral_response`). +3. **Indirect-tax response**: *not modelled*. Households' consumption + bundles are read from the dataset and don't shift in response to + income changes. + +#1114 reports that UKMOD handles this in its indirect tax (TCO) module with +an aggregate **consumption elasticity of 0.8**: a 1% rise in net income gives a 0.8% rise in +consumption, which then flows through VAT and excise revenue. That figure +has not yet been checked against UKMOD's own documentation, so it needs a +primary source before it is used as a default. + +## Scope + +### Phase 1: household-level consumption response + +- New parameter `gov/simulation/indirect_tax_response/consumption_elasticity.yaml`, + defaulting to 0 (no response) until a sourced value such as UKMOD's is + confirmed and cited in its metadata. +- New variable `consumption_response_factor` (Household, YEAR) = + `1 + elasticity * (net_income_change / baseline_net_income)`. +- Apply the factor to the twelve COICOP categories that `consumption` + adds up (`food_and_non_alcoholic_beverages_consumption`, + `alcohol_and_tobacco_consumption`, `transport_consumption` and so on). + VAT reads `consumption` (through `full_rate_vat_consumption` and + `reduced_rate_vat_consumption`), so it picks up the change. +- The duties do not read those categories. Fuel duty reads + `petrol_litres` and `diesel_litres`, which divide the `petrol_spending` + and `diesel_spending` inputs by pump prices. Alcohol duty reads the + `*_litres` inputs under `variables/input/consumption/alcohol/`, and + tobacco duty reads `cigarettes_per_week` and the `*_grams_per_week` + inputs under `variables/input/consumption/tobacco/`. The same factor + needs to be applied to those spending and quantity inputs. + +### Phase 2: heterogeneity by category + +- A single economy-wide elasticity hides that some categories (food + staples, domestic energy) are nearly income-inelastic while others + (recreation, restaurants) are not. Replace the single + `consumption_elasticity.yaml` with a per-category set, calibrated to + ONS *Living Costs and Food Survey* income-elasticity estimates. + +### Phase 3: incidence link + +- Today's `consumer_incident_tax_revenue_change` distributes a + user-specified aggregate across households using consumption shares + (see + [`consumer_incident_tax_revenue_change.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/policyengine/consumer_incident_tax_revenue_change.py)). + In Phase 3 the variable derives its aggregate **from** the Phase-2 + variables so the user doesn't need to set a separate budget lever. + +## Implementation outline + +### Variables + +- `consumption_response_factor` (Household, YEAR), Phase 1. +- `disposable_income_change` (Household, YEAR): the difference between + reform and baseline `household_net_income`. The labour-supply module + already computes a clipped relative version of this per person + (`relative_income_change`, under + `variables/gov/simulation/labour_supply_response/`); Phase 1 needs a + household-level version in pounds. +- Optional `consumption_response_factor_by_category` (Household, + per category), Phase 2. + +### Hook points + +Each `*_consumption` input variable currently reads straight from the +dataset. Two options for the hook: + +1. **Decorate at the input layer**: add `defined_for` / + `default_formula` that multiplies by `consumption_response_factor`. + This is clean but ties the consumption inputs to indirect-tax + modelling. +2. **Insert a derived layer**: keep `*_consumption_baseline` (reads from + data) and introduce `*_consumption` formulas that apply the response + factor. This is the safer route: users who do not need the indirect + tax response keep the pre-reform consumption as + `*_consumption_baseline`. + +Recommendation: route (2). The dataset-side variable becomes +`*_consumption_baseline`; the response factor is applied at +`*_consumption`. + +### Tests + +- A reform that drops income tax for low-income households should + produce a *positive* `consumer_incident_tax_revenue_change` aggregate + (more take-home pay -> more consumption -> more VAT). +- A reform that raises NI should produce a *negative* aggregate. +- The aggregate should track elasticity × (sum of net income change × VAT-equivalent rate) + to within a small tolerance. + +## Open questions + +- Should the elasticity vary by household income decile? Empirical + evidence suggests low-income households have higher marginal + consumption propensity. +- Should saving be modelled explicitly (the residual after consumption)? + Phase 1 implicitly assumes any income change above the elasticity + applies to saving. +- How does this interact with the existing labour-supply response so + we don't double-count? + +## References + +- UKMOD documentation, to confirm the 0.8 consumption elasticity reported in #1114 (not yet checked). +- ONS [Living Costs and Food Survey](https://www.ons.gov.uk/peoplepopulationandcommunity/personalandhouseholdfinances/expenditure/bulletins/familyspendingintheuk/latest): income-elasticity calibration source for Phase 2. +- HMRC, [VAT annual statistics](https://www.gov.uk/government/statistics/value-added-tax-vat-annual-statistics): calibration target for the aggregate. +- Existing infrastructure: [`consumer_incident_tax_revenue_change`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/policyengine/consumer_incident_tax_revenue_change.py) and the `gov/simulation/labour_supply_responses/` parameter tree for the parallel labour-supply pattern. +- Related issue: [#1114](https://github.com/PolicyEngine/policyengine-uk/issues/1114). diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 29f7dafb3c..216a78ee1c 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -8,7 +8,7 @@ PolicyEngine UK models the UK tax and benefit system as of 2025, incorporating m ### Budget 2025 threshold freezes -Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. Finance Act 2021 s.5, as amended by Finance Act 2023 s.5 and Finance Act 2026 s.10, fixes the [personal allowance](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml) at £12,570 and the [basic rate limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml) at £37,700 for 2022-23 to 2030-31. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £5,000 a year through 2030-31. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. +Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. Finance Act 2021 s.5, as amended by Finance Act 2023 s.5 and Finance Act 2026 s.10, fixes the [personal allowance](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml) at £12,570 and the [basic rate limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml) at £37,700 for 2022-23 to 2030-31. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £5,000 a year through 2030-31. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. From 2031-32 the model indexes these thresholds by September CPI, in [`create_threshold_indexation.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The personal allowance and basic rate limit follow Income Tax Act 2007 ss.57 and 21 year by year: each rises by the CPI increase to the previous September, with the allowance's increase rounded up to a multiple of £10 and the limit rounded up to a multiple of £100. The government says the National Insurance thresholds will also be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI 2026/231, para 5.1), and it has kept them aligned with income tax through the freeze (HMRC's Budget 2025 policy paper on the threshold freeze; Explanatory Memorandum to SI 2026/231, paras 5.14 and 5.15). The model assumes the alignment continues, round-ups included: the primary threshold and lower profits limit equal the personal allowance, and the upper earnings and profits limits equal the higher rate threshold (the personal allowance plus the basic rate limit). It also assumes the secondary threshold rises by September CPI, unrounded; the Autumn Budget 2024 policy paper says only that it will be increased in line with CPI. None of these National Insurance rules after 2030-31 is law. @@ -16,7 +16,7 @@ From 2031-32 the model indexes these thresholds by September CPI, in [`create_th ### Autumn Budget 2024 -The government increased the [employer National Insurance rate from 13.8% to 15%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml#L30-L35) and reduced the [employer secondary threshold from £9,100 to £5,000 annually](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml#L26-L30), both taking effect in fiscal year 2025-26. +The government increased the [employer National Insurance rate from 13.8% to 15%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml) and reduced the [employer secondary threshold from £9,100 to £5,000 annually](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml), both taking effect in fiscal year 2025-26. ### Universal Credit rebalancing @@ -30,7 +30,7 @@ Annual benefit uprating applied 4.1% increases to state pensions and 1.7% to wor ### Autumn Statement 2023 -The government announced further National Insurance cuts, reducing the [employee main rate from 10% to 8%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L24-L29) and the [self-employed Class 4 rate from 9% to 6%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml#L26-L33) in fiscal year 2024-25. [Class 2 National Insurance contributions were abolished](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_2/flat_rate.yaml#L51-L56) for self-employed people, with the flat rate set to £0 in fiscal year 2024-25. +The government announced further National Insurance cuts, reducing the [employee main rate from 10% to 8%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) and the [self-employed Class 4 rate from 9% to 6%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml) in fiscal year 2024-25. [Class 2 National Insurance contributions were abolished](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_2/flat_rate.yaml#L51-L56) for self-employed people, with the flat rate set to £0 in fiscal year 2024-25. ### Capital gains tax changes (Autumn Budget 2024) @@ -42,7 +42,7 @@ The government increased the [child benefit high income tax charge threshold fro ### Autumn Statement 2023 (Employee NI cut) -An initial National Insurance cut reduced the [employee main rate from 12% to 10%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L18-L23) in fiscal year 2024-25. +An initial National Insurance cut reduced the [employee main rate from 12% to 10%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) in fiscal year 2024-25. ## 2023 reforms @@ -54,11 +54,11 @@ The government reduced the [additional rate income tax threshold from £150,000 ### Spring Statement 2022 -The government announced an increase in the [National Insurance primary threshold from £9,880 to £12,570](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml#L17-L22), aligning it with the personal allowance in fiscal year 2022-23. +The government announced an increase in the [National Insurance primary threshold from £9,880 to £12,570](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml), aligning it with the personal allowance in fiscal year 2022-23. ### Autumn Budget 2021 (Health and Social Care Levy) -The Health and Social Care Levy Act 2021 raised the [employee main rate from 12% to 13.25%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml#L4-L17) and the [employer rate from 13.8% to 15.05%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml#L18-L29) for fiscal year 2022-23. The Health and Social Care Levy (Repeal) Act 2022 restored 12% and 13.8% for earnings paid from 6 November 2022. The model reads each fiscal year's rates at 30 April, so 2022-23 carries the levy rates for the whole year ([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)). +The Health and Social Care Levy Act 2021 raised the [employee main rate from 12% to 13.25%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml) and the [employer rate from 13.8% to 15.05%](https://github.com/PolicyEngine/policyengine-uk/blob/e1601dbe4feb15e71d0d59b7a57f9d860741c068/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml) for fiscal year 2022-23. The Health and Social Care Levy (Repeal) Act 2022 restored 12% and 13.8% for earnings paid from 6 November 2022. The model reads each fiscal year's rates at 30 April, so 2022-23 carries the levy rates for the whole year ([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)). ### Mini-Budget 2022 (September) @@ -122,3 +122,9 @@ Notable exclusions: - **Some devolved tax policies**: Beyond property transaction taxes, other devolved policies may have limited coverage All parameter values include references to primary legislation and can be found in the [PolicyEngine UK parameters directory](https://github.com/PolicyEngine/policyengine-uk/tree/master/policyengine_uk/parameters). + +## Parameter cache ownership + +The UK tax-benefit system initializes Core state and installs parameter trees through Core's public operations. The existing `reset_parameter_caches()` method clears dated views on the system, the root and descendant parameter nodes without replacing the live tree or its leaves. Parameter processing retains its statutory timelines, baseline, structural reform nodes and rollback behavior; this change does not alter policy values or reform timing. + +The selected operations are available at the existing Core minimum version, 3.32.27, and in the locked 3.33.2 release, so this parameter-only migration does not change the dependency or lockfile. It implements part of [issue #2175](https://github.com/PolicyEngine/policyengine-uk/issues/2175). Supplied-input tracking, model-value provenance and clone, deletion and rebuild compatibility remain separate work. Those compatibility paths are retained, including the distinction between an amount supplied by a user and one stored by the model. diff --git a/docs/book/policy/uc-rebalancing.md b/docs/book/policy/uc-rebalancing.md index 89ac3d4db3..0c5cb4382f 100644 --- a/docs/book/policy/uc-rebalancing.md +++ b/docs/book/policy/uc-rebalancing.md @@ -24,7 +24,7 @@ Universal Credit Act 2025 s. 3 freezes the element for 2026-27 to 2029-30: it di Two parameters hold this amount, and both are uprated by `gov.economic_assumptions.indices.uc_health_element`, so they follow the same path: -- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. A simulation uses it whenever the rebalancing reforms are inactive, and from 2033-34, after the data's projection ends, when the rebalancing scenario no longer sets the element. +- `gov.dwp.universal_credit.elements.disabled.amount` is the LCWRA element the `uc_LCWRA_element` formula pays. For an inactive year processed by the rebalancing scenario, the scenario replaces that paid amount with the pre-Act counterfactual described below. From 2033-34, after the data's projection ends, the rebalancing scenario no longer sets the element and the formula reads this parameter. - `gov.dwp.universal_credit.rebalancing.new_claimant_health_element` is the amount the rebalancing scenario pays new claimants in the years it sets the element, `uc_reform.REBALANCING_YEARS` (2026-27 to 2032-33). A change to the freeze or to the benefit uprating index moves both, and so does a reform that sets the index, `gov.economic_assumptions.indices.uc_health_element`, directly (it replaces the freeze and the no-fall floor for the years it names). A reform to either amount itself does not change the other. @@ -92,6 +92,8 @@ scenario = Scenario( sim = Simulation(scenario=scenario) ``` +For the health component, the inactive 2026-27 amount is a declared CPI-only counterfactual: the 2025-26 monthly LCWRA amount (£423.27) increased by September 2025 CPI (3.8%) and rounded to monthly pennies (£439.35, or £5,272.20 a year). SSAA 1992 section 150 allows discretion over Universal Credit uprating and rounding; this convention is not an enacted LCWRA rate or the only legally possible amount. From 2027-28, the model's ordinary benefit uprating index projects growth from that 2026-27 anchor. It does not apply the new/protected claimant split, the health-element freeze or the protected-amount floor. Each inactive year starts independently from that fixed 2026-27 anchor, so a mixed on/off schedule cannot feed an inactive award into the later protected calculation. Explicit LCWRA amount reforms and directly supplied element awards retain precedence. Applying an amount change through `Scenario.apply` also refreshes an already-calculated health element and final Universal Credit award. The underlying `elements.disabled.amount` parameter still holds the legislated new-claimant amount; this counterfactual changes the paid health element through the scenario. This is a health-component counterfactual, not validation that the toggle disables every provision of the Act. The scenario covers tax years through 2032-33; from 2033-34 the inactive setting falls back to the underlying amount path, so it does not provide a continuous long-run “Act off” projection. + The 2026-27 standard allowance stays at its legislated rates, which include the 2.3% uplift; from 2027-28 it follows CPI alone from the 2026-27 rates less that uplift. ### Changing the standard allowance uplift parameters diff --git a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb index 4695c82a59..af05368b97 100644 --- a/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb +++ b/docs/book/programs/contrib/ubi-center/carbon-tax.ipynb @@ -7,53 +7,45 @@ "source": [ "# Carbon taxes\n", "\n", - "A carbon tax is a tax levied on the carbon emissions required to produce goods and services. In this way, they are designed to reduce carbon dioxide emissions by increasing prices. This both decreases demand for such goods and services and incentivizes efforts to make them less carbon-intensive. In its simplest form, a carbon tax covers only CO2 emissions; however, it can also cover other greenhouse gasses, such as methane or nitrous oxide, by calculating their global warming potential relative to CO2 emissions. PolicyEngine’s carbon tax model is consumption-based - that is, the tax is levied on the UK’s consumption of CO2-intensive goods, rather than the UK’s production of CO2. Table 1 shows the incidence of this carbon tax model:\n", + "This page describes PolicyEngine UK's contributed carbon-tax model. It estimates a hypothetical household burden from consumption-related emissions, then allocates that burden between consumers and exposure to the corporate sector. It does not simulate an enacted carbon tax or a border adjustment.\n", "\n", - "Table 1\n", + "## Domestic and overseas flows\n", "\n", + "Production location and consumption location are different questions. The model estimates the footprint of UK households' consumption without a country-of-production dimension; it does not measure each of the following flows separately.\n", "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
\n", - " UK\n", - " Non-UK\n", - "
Owners of production\n", - " Reality: directly taxed by a carbon emissions tax, not indirectly taxed.\n", - "

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

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

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

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

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

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

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

\n", + "| Where goods and services are produced | Where they are consumed | What the household model represents |\n", + "| --- | --- | --- |\n", + "| UK | UK households | Part of the household consumption footprint, without a separate domestic-production component. |\n", + "| Outside the UK | UK households | [DEFRA's consumption-based footprint](https://www.gov.uk/government/statistics/uks-carbon-footprint) includes emissions embedded in imports. The model combines these with domestic emissions without a UK/overseas production split or a border-adjustment calculation. |\n", + "| UK | Outside the UK | No foreign-consumer population or separate export flow in the carbon-tax calculation. Stored UK production series are not its tax base. |\n", + "| Outside the UK | Outside the UK | No separate rest-of-world consumption or production flow in the UK household calculation. |\n", "\n", + "This distinction also applies to ownership. A household's exposure to corporate taxes does not identify where a company produces goods or where its customers live. The model does not calculate a separate burden for foreign owners or consumers, or separate employer and wage effects.\n", "\n", - "## Methodology summary\n", + "## From consumption to emissions\n", "\n", - "We model carbon taxes by simulating household net incomes through the microsimulation model. The first step: collecting the Family Resources Survey inputs: data on earnings, benefit receipt and demographics for 20,000 households. Then, we load the Living Costs and Food Survey, which has less granular data on earnings, and benefit receipt, but detailed capture of goods and services produced: we impute these consumption variables to the FRS. We then use the National Carbon Footprint dataset to calculate the intensities of certain categories (e.g. the carbon emissions associated with a pound spent on clothing), and use this to estimate the carbon footprint of each FRS household. After we integrate these carbon footprints to the FRS, we run a full microsimulation to simulate disposable incomes with and without a carbon tax.\n", + "[`carbon_consumption`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/consumption/carbon.py) is an annual household estimate in tonnes of CO2. It adds spending in twelve consumption categories, each multiplied by that category's carbon intensity. The intensity is aggregate category emissions divided by aggregate category spending; when that spending is nonpositive, the formula uses the stored fallback intensity.\n", "\n", + "The [carbon parameters](https://github.com/PolicyEngine/policyengine-uk/tree/main/policyengine_uk/parameters/household/consumption/carbon) also contain production and total-consumption series. The household footprint and carbon-tax formulas do not read those series to construct a territorial inventory or a production-to-consumption trade balance. Their presence does not establish separate coverage of all four flows above.\n", "\n", - "### Appendix: Carbon intensities" + "Consumption inputs, intensities and survey weights determine the resulting footprint. A sum over the simulated households is not by itself a validated UK national emissions total; that interpretation also requires suitable data and weights. This page describes the calculation, without validating a particular dataset's imputation or source-country coverage.\n", + "\n", + "## How the burden is allocated\n", + "\n", + "For a household with footprint `E_h`, the [`carbon_tax` formula](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/contrib/ubi_center/carbon_tax.py) is:\n", + "\n", + "```text\n", + "E_total = sum(household_weight_h * E_h)\n", + "carbon_tax_h = c * r * E_h + (1 - c) * r * E_total * shareholding_h\n", + "```\n", + "\n", + "Here `r` is the hypothetical price per tonne and `c` is the consumer-incidence fraction, both set by the [carbon-tax parameters](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/contrib/ubi_center/carbon_tax.yaml). The first term allocates the consumer share by the household's footprint. The second allocates the remaining burden using [`shareholding`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/wealth/shareholding.py), the household's modelled corporate-sector wealth exposure. With a dataset and nonzero corporate wealth, that exposure is normalised by weighted household corporate wealth; otherwise the formula uses the national-balance-sheet denominator.\n", + "\n", + "This is an allocation assumption, not a calculation of sector-specific demand elasticities, international ownership or the legal incidence of an import tax. Changing the incidence fraction changes who bears the modelled burden, without adding a country-of-production breakdown.\n", + "\n", + "### Appendix: 2019 DEFRA fallback intensities\n", + "\n", + "The chart below uses the 2019 DEFRA fallback intensities stored in the [carbon-intensity parameters](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/household/consumption/carbon/intensity.yaml). Its code cell fixes these twelve values and does not query the current carbon parameters. The chart is not a new estimate or validation of domestic and overseas emissions.\n" ] }, { diff --git a/docs/book/programs/gov/cms/child-maintenance.md b/docs/book/programs/gov/cms/child-maintenance.md new file mode 100644 index 0000000000..7dc437cd20 --- /dev/null +++ b/docs/book/programs/gov/cms/child-maintenance.md @@ -0,0 +1,135 @@ +# Child maintenance (planned) + +```{warning} +**Not yet modelled in detail.** PolicyEngine UK has two generic person-level +inputs, `maintenance_income` and `maintenance_expenses`. They do not tell +child maintenance apart from maintenance between former partners, and the +model does not calculate a Child Maintenance Service (CMS) award. This page +sets out the scope for doing that, tracked in +[#669](https://github.com/PolicyEngine/policyengine-uk/issues/669) (still +open, October 2026). +``` + +## What the model does today + +- `maintenance_income` is added to `market_income`, `household_market_income` + and `hbai_household_net_income`. +- `maintenance_expenses` is subtracted in `market_income` and + `hbai_household_net_income`. +- Neither variable feeds any means test. Universal Credit unearned income is + the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`, + which contains no maintenance, and UC earned income does not net off + maintenance paid. +- `uc_deductions` does cover child maintenance collected from a paying + parent's UC award, but only in aggregate: it sits in the above-cap excess + of `uc_deduction_rate` with last resort deductions, estimated from DWP + deduction data rather than from `maintenance_expenses`. + +For **child** maintenance that is the right answer for UC: + +- **Receiving parent:** child maintenance is not unearned income under + [UC Regulations 2013 reg. 66(1)](https://www.legislation.gov.uk/uksi/2013/376/regulation/66), + so it is fully disregarded. +- **Paying parent:** maintenance paid is not deducted, so the paying parent is + means-tested on their full earnings. + +This asymmetry is the work-incentive problem described by the Centre for Social +Justice in [*The Hidden Parent Poverty Trap: Child Maintenance and Universal +Credit*][csj]. The model already reproduces it, because neither input reaches +UC. + +The gap is **maintenance between former spouses or civil partners**. +Reg. 66(1)(d) counts it as unearned income in UC, but the model cannot see it, +because it shares `maintenance_income` with child maintenance. Those +recipients' UC is overstated. + +## Scope + +### Phase 1: separate the inputs + +- New input `child_maintenance_received` (Person, GBP, annual). +- New input `child_maintenance_paid` (Person, GBP, annual). +- Keep `maintenance_income` and `maintenance_expenses` for maintenance + between former partners, and say so in their documentation. +- In the data pipeline + ([Microcosm UK](https://github.com/PolicyEngine/microcosm)), route the FRS + child-maintenance items to the new inputs and the spousal items to the + existing ones. +- HBAI income keeps both kinds, received and paid. + +### Phase 2: count spousal maintenance in UC + +- Add `maintenance_income` (now spousal only) to the UC unearned income list + from 2013-04-29, citing reg. 66(1)(d). +- Leave `child_maintenance_received` and `child_maintenance_paid` out of UC. +- Check the legacy benefits and Pension Credit the same way before adding + either input to their income definitions. + +### Phase 3: model the CMS calculation + +Derive the CMS weekly amount when the inputs are available, from the paying +parent's gross weekly income +([GOV.UK](https://www.gov.uk/how-child-maintenance-is-worked-out); +[Child Support Act 1991 Sch. 1](https://www.legislation.gov.uk/ukpga/1991/48/schedule/1)): + +| Paying parent's gross weekly income | Rate | Weekly amount | +|---|---|---| +| Below £7 | Nil | £0 | +| £7 to £100, or on benefits | Flat | £7 | +| £100.01 to £199.99 | Reduced | Formula | +| £200 to £3,000 | Basic | 12% / 16% / 19% of income up to £800 for 1 / 2 / 3+ children, then 9% / 12% / 15% of income from £800 to £3,000 | +| Above £3,000 | Basic, capped | Income above £3,000 is ignored; the receiving parent can apply to the courts | +| Not enough information | Default | £38 / £51 / £64 | + +Then: + +- **Shared care:** the amount falls by 1/7 for 52 to 103 nights a year, 2/7 + for 104 to 155, 3/7 for 156 to 174, and by half plus £7 a week for 175 or + more. +- **Other children:** an adjustment applies for other children living with + the paying parent. + +The income bands are fixed in legislation, not uprated each year. + +Phase 3 is optional for UC analysis, which needs only Phases 1 and 2. It +allows reforms to the CMS rates or bands to be scored. + +## Implementation outline + +### Parameters under `gov/cms/` + +- `income_bands/`: the £7, £100, £200, £800 and £3,000 limits. +- `rate/flat.yaml`, `rate/default/` and `rate/basic/` (12/16/19% and + 9/12/15%). +- `rate/reduced/`: the reduced-rate formula. +- `shared_care/`: the night bands and fractions. + +### Variables under `variables/gov/cms/` + +- `child_maintenance_gross_weekly_income` (Person) +- `child_maintenance_obligation_pre_shared_care` (Person) +- `child_maintenance_shared_care_reduction` (Person) +- `child_maintenance_paid_cms_basis` (Person, derived award) + +## Data needs + +- **FRS** records maintenance received and paid at person level. Phase 1 + needs a check of how far its source items separate child maintenance + from spousal maintenance. +- **DWP [Child Maintenance Service statistics][cms-stats]** (quarterly, latest + data to June 2026) give the CMS caseload and amounts. They support checking + the new inputs, bearing in mind that many families use private + ("family-based") arrangements outside the CMS. + +## References + +- Centre for Social Justice, [The Hidden Parent Poverty Trap: Child + Maintenance and Universal Credit][csj]. +- GOV.UK, [How child maintenance is worked out](https://www.gov.uk/how-child-maintenance-is-worked-out). +- [Child Support Act 1991](https://www.legislation.gov.uk/ukpga/1991/48/contents) and [Schedule 1](https://www.legislation.gov.uk/ukpga/1991/48/schedule/1). +- [The Child Support Maintenance Calculation Regulations 2012 (SI 2012/2677)](https://www.legislation.gov.uk/uksi/2012/2677/contents). +- [The Universal Credit Regulations 2013, reg. 66](https://www.legislation.gov.uk/uksi/2013/376/regulation/66). +- DWP, [Child Maintenance Service statistics][cms-stats]. + +[csj]: https://www.centreforsocialjustice.org.uk/library/the-hidden-parent-poverty-trap-child-maintenance-and-universal-credit +[cms-stats]: https://www.gov.uk/government/collections/statistics-on-the-2012-statutory-child-maintenance-scheme diff --git a/docs/book/programs/gov/dfe/regional-childcare-plan.md b/docs/book/programs/gov/dfe/regional-childcare-plan.md new file mode 100644 index 0000000000..225a5291e6 --- /dev/null +++ b/docs/book/programs/gov/dfe/regional-childcare-plan.md @@ -0,0 +1,170 @@ +# Regional childcare programmes (planned) + +```{warning} +**Partially modelled.** PolicyEngine UK models the **England** funded +childcare programmes (Universal, Extended and Targeted Childcare +Entitlements; Care to Learn; Student Finance England Childcare Grant) +and the **UK-wide** Tax-Free Childcare scheme. The devolved +administrations operate parallel schemes that are not yet modelled +beyond the Scottish Universal Funded ELC for 3- and 4-year-olds added +in [#1643][pr-1643]. This page captures the proposed scope, tracked +under [#1008](https://github.com/PolicyEngine/policyengine-uk/issues/1008) +and the broader [#1644 audit][issue-1644]. +``` + +## What is modelled today + +The current childcare coverage is captured in +[`policyengine_uk/programs.yaml`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/programs.yaml) (see the +[#1644 split into per-programme rows][issue-1644]) and works as follows: + +- **UK-wide**: Tax-Free Childcare (HMRC); Universal Credit childcare + element (DWP); Working Tax Credit childcare element (legacy, HMRC + administered, modelled under `gov/dwp/`; see + [tax-credits.md](./../dwp/tax-credits.md)). +- **England-only** (gate on `country == ENGLAND`): + Universal Childcare Entitlement (15 hours for 3- and 4-year-olds), + Extended Childcare Entitlement (working parents, now reaching down to + children aged nine months), Targeted Childcare Entitlement (2-year-olds + in families on qualifying benefits or low incomes), Care to Learn, + Student Finance England Childcare Grant. +- **Scotland**: eligibility for the universal funded ELC for 3- and + 4-year-olds (`scottish_funded_early_learning_childcare_eligible`, + including the statutory start-date rule for 3-year-olds) and its 1,140 + hours a year, added in [#1643][pr-1643]. No funding value is attached + yet, so it does not reach household income. +- **Wales, Northern Ireland**: no programmes modelled. + +## Proposed scope by jurisdiction + +### Scotland: three remaining strands + +The [#1644 audit][issue-1644] flagged three Scottish gaps: + +1. **Eligible-two-year-old strand** of the funded ELC programme + (vulnerable or low-income 2-year-olds under the + [Provision of Early Learning and Childcare (Specified Children) + (Scotland) Order 2014][sssi-196-2014]). Modelled the same way as the + 3- and 4-year-old strand but gated on UC, tax credits or other + qualifying benefits. +2. **Student Awards Agency Scotland (SAAS) Lone Parents' Grant, + Dependants' Grant, and Childcare Fund** for full-time HE students: + the Scottish equivalent of Student Finance England's Childcare + Grant. Income-tested. +3. **Support for young parents in further education**, the role Care to + Learn plays in England. The [Education Maintenance Allowance][scot-ema] + still runs in Scotland for 16- to 19-year-olds, applied for through + local councils and colleges, but it is a maintenance payment rather + than childcare support, so the childcare side needs its own scoping. + +### Wales + +The audit identified four Welsh programmes: + +1. **Childcare Offer for Wales**: 30 hours a week for working parents + of 3- and 4-year-olds (term time plus some holiday weeks). + Modelled the same as the English Extended Childcare Entitlement + with Welsh-specific income tests and hour caps. Statutory basis: + [Childcare Funding (Wales) Act 2019][wales-act]. +2. **Flying Start**: a universal offer in disadvantaged areas for + 2- to 3-year-olds (12.5 hours a week). Eligibility is geographic, + based on lower layer super output areas. +3. **Student Finance Wales Childcare Grant**, parallel to the English + Childcare Grant. +4. **Welsh EMA**, parallel to the Scottish EMA. + +### Northern Ireland + +The audit identified four NI programmes: + +1. **Pre-school Education Programme (PSEP)**: 12.5 hours a week in the + year before school. Universal. +2. **Sure Start**: younger children in disadvantaged areas, including a + programme for 2-year-olds. +3. **Student Finance NI Childcare Grant**. +4. **NI EMA**. + +## Implementation outline + +For each new programme: + +- New parameter tree under `parameters/gov///` + capturing the income tests, hours offered, and weekly funding rates. +- New variable(s) under `variables/gov///` gated on + the appropriate `country == ...` check. +- A `_eligible` companion variable that the new programme + variable composes with the rate parameter, the same pattern as the + English entitlement trio. +- A `programs.yaml` row per programme, bound to the new variable and + parameter prefix. + +### Structural items deferred to this work + +The [#1644 audit][issue-1644] also flagged two structural items that +become more pressing once the regional programmes land: + +- `weeks_per_year` and `childcare_funding_rate` currently sit at + `gov.dfe.*` root but encode English term-week and funding-rate + assumptions. They should be regionalised (for example + `gov.scottish_government.funded_early_learning_childcare.weeks_per_year`) + once the Scotland, Wales and NI programmes are modelled. +- No aggregating variable across programmes exists (for example "total + subsidised childcare hours per child per week"). The API surfaces + the per-programme variables but can't answer "what's this family's + combined childcare support" without the caller summing. + +Both belong in a separate cleanup PR rather than mixed into the +per-programme work. + +## Data needs + +Most regional programmes are point-in-time per-hour funding rather +than per-household payments, so the data side is comparatively easy: + +- **FRS** provides `region` and `country` at the household level, + enough to gate eligibility. +- **Welsh Government Statistical Bulletins** publish Flying Start + and Childcare Offer caseloads by local authority. +- **Scottish Government Childcare and Early Years statistics** provide + Scottish funded ELC caseloads. +- **Department of Education NI** publishes PSEP caseloads. +- The SAAS, Student Finance Wales and NI childcare grants need the same + treatment as the English `childcare_grant`: calibration against + published award totals. + +The aggregating challenge is choosing whether to expose **subsidised +hours** or **subsidised cost** as the primary household-level +variable. Recommendation: subsidised cost, since that is how the English +entitlements already enter `household_benefits` and `gov_spending`. + +## Open questions + +- Should the Scottish ELC universal strand and the eligible-two-year-old + strand be modelled as two separate variables (matching how England + splits Universal / Extended / Targeted), or as one variable with the + age-and-eligibility logic inside? Recommendation: separate, mirroring + the English pattern for queryability. +- Welsh Flying Start is geographic. Microcosm UK, the dataset being + built to replace the Enhanced FRS, assigns each household in England + and Wales to a 2021 Output Area, which nests in LSOAs, so eligibility + by area becomes possible on that dataset once the Flying Start area + list is sourced. +- EMA ended in England but still runs in Scotland, Wales and Northern + Ireland. Today `child_ema` and `adult_ema` are reported amounts with + no formula. Should the devolved schemes be modelled under the existing + `gov.dfe.care_to_learn` umbrella or as separate regional schemes? + +## References + +- Audit: [#1644](https://github.com/PolicyEngine/policyengine-uk/issues/1644), the full childcare coverage audit. +- Tracking: [#1008](https://github.com/PolicyEngine/policyengine-uk/issues/1008), the original Scotland, Wales and NI gap report. +- Recent landed work: [#1643][pr-1643], Scottish universal funded ELC. +- Existing England docs (for the pattern to mirror): the entitlement variables under [`policyengine_uk/variables/gov/dfe/`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/dfe). +- Welsh childcare statute: [Childcare Funding (Wales) Act 2019][wales-act]. +- Scottish eligible-2-year-old statute: [SSSI 2014/196][sssi-196-2014]. + +[issue-1644]: https://github.com/PolicyEngine/policyengine-uk/issues/1644 +[pr-1643]: https://github.com/PolicyEngine/policyengine-uk/pull/1643 +[wales-act]: https://www.legislation.gov.uk/anaw/2019/1/contents +[sssi-196-2014]: https://www.legislation.gov.uk/ssi/2014/196/contents/made +[scot-ema]: https://www.mygov.scot/ema diff --git a/docs/book/programs/gov/dwp/bereavement-support-plan.md b/docs/book/programs/gov/dwp/bereavement-support-plan.md new file mode 100644 index 0000000000..9499139048 --- /dev/null +++ b/docs/book/programs/gov/dwp/bereavement-support-plan.md @@ -0,0 +1,179 @@ +# Bereavement support: BSP formula and historical WPA (planned) + +```{warning} +**Currently reported-only.** PolicyEngine UK models the Bereavement +Support Payment (BSP) as `adds = ["bsp_reported"]`, so the value +flows through from the dataset, with no derived eligibility or +rule-based amount. This page captures the proposed formula-based +extension covering both BSP and the legacy Widowed Parent's Allowance +(WPA) for historical analysis. Tracks +[#466](https://github.com/PolicyEngine/policyengine-uk/issues/466) and +the related historical-WPA ask in +[#465](https://github.com/PolicyEngine/policyengine-uk/issues/465). +Updated October 2026: still reported-only; the facts below were checked +against GOV.UK and legislation.gov.uk. The data notes cover both the +certified Enhanced FRS (policyengine-uk-data) and Microcosm UK, the +dataset being built to replace it. +``` + +## What's currently modelled + +Just the FRS-reported amount: + +```python +class bsp(Variable): + value_type = float + entity = Person + label = "Bereavement Support Payment" + definition_period = YEAR + unit = GBP + + adds = ["bsp_reported"] +``` + +That means: + +- Reform analyses changing BSP rates have no household-level surface. +- Anyone bereaved after the FRS reference week never appears as a BSP + recipient even if eligible. +- The legacy WPA has no variable of its own. Both the Enhanced FRS + ([`frs.py`](https://github.com/PolicyEngine/policyengine-uk-data/blob/main/policyengine_uk_data/datasets/frs.py)) + and Microcosm UK fill `bsp_reported` from FRS benefit codes 6 and 9 + ([`frs_spine.py`](https://github.com/PolicyEngine/microcosm/blob/75167a68/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py#L825)), + and Microcosm's own + [source notes](https://github.com/PolicyEngine/microcosm/blob/d1fda720/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml#L1870) + say code 6 mixes benefits and code 9 is tax-free War Widow's Pension. So the reported "BSP" also carries legacy + widow's benefits and War Widow's Pension. +- Since [#2382](https://github.com/PolicyEngine/policyengine-uk/pull/2382) + (2.135.7), War Widow's Pension has its own variables, + `war_widows_pension_reported` and `war_widows_pension`. `bsp_reported` + keeps CPI uprating only until the datasets move FRS code 9 out of it + ([policyengine-uk-data#578](https://github.com/PolicyEngine/policyengine-uk-data/issues/578), + [microcosm#1222](https://github.com/PolicyEngine/microcosm/issues/1222)); + [#2381](https://github.com/PolicyEngine/policyengine-uk/issues/2381) + tracks freezing it after that. + +## Proposed scope + +### Phase 1: rule-based BSP formula + +The BSP under the [Pensions Act 2014, Part 5][pa-2014-5] and the +[Bereavement Support Payment Regulations 2017][bsp-regs] (SI 2017/410) +pays: + +- A **lump sum** of either £2,500 (standard rate) or £3,500 (higher + rate, with a dependent child). +- **18 monthly payments** of £100 (standard) or £350 (higher), for a + death on or after 6 April 2017 + ([GOV.UK](https://www.gov.uk/bereavement-support-payment/what-youll-get)). + +The higher rate applies if, when the partner died, the claimant was +getting (or entitled to) Child Benefit for a child living with them, or +was pregnant. The +[Bereavement Benefits (Remedial) Order 2023](https://www.legislation.gov.uk/uksi/2023/134/contents) +(SI 2023/134) extended BSP and WPA to cohabiting partners with +children, with effect back to 30 August 2018, after the Supreme Court's +2018 McLaughlin judgment. It didn't change the number of payments. + +To model this we need: + +- `bsp_initial_payment_due` (Person, YEAR): a new input that is true + only in the year the BSP claim is made after the partner's death, so + the [single initial payment](https://www.gov.uk/bereavement-support-payment/what-youll-get) + is paid once. A flag for "bereaved in the last 18 months" would stay + true across two consecutive annual periods and pay the lump sum twice. +- `bsp_higher_rate_eligible` (Person, YEAR): whether they have a + dependent child. +- `bsp_months_remaining` (Person, YEAR): how many of the 18 monthly + instalments fall within the simulation year (0 to 12). This is where + the 18-month window belongs. + +The BSP formula then becomes: + +``` +bsp = ( + lump_sum_amount * bsp_initial_payment_due + + monthly_amount * bsp_months_remaining +) +``` + +An alternative is a single date-of-death (or claim-date) input from +which both the initial-payment year and the months in each year are +derived. Either way the lump sum is tied to one year only. + +Both `lump_sum_amount` and `monthly_amount` parameter trees split on +`bsp_higher_rate_eligible` (£3,500 / £350 vs £2,500 / £100). + +These parameters should be held flat, with no `uprating` key. BSP is +not uprated: the four amounts in [SI 2017/410 reg 3][bsp-reg-3] have +been £350, £3,500, £100 and £2,500 since April 2017, and the +[Social Security Benefits Up-rating Order 2026][uprating-2026] +(SI 2026/148), art 19, says each of them "remains unchanged". + +### Phase 2: historical WPA under the same formula + +Widowed Parent's Allowance (under [Social Security Contributions and +Benefits Act 1992 s. 39A][sscba-39a]) was paid for deaths from April +2001 up to 5 April 2017. The benefit: + +- Continued for as long as the surviving spouse had a dependent child. +- Was uprated annually. +- Was replaced by BSP for deaths from 6 April 2017. + +For historical analyses the model should: + +- Add `wpa_eligible` (Person, YEAR): partner died between April 2001 + and 5 April 2017, the survivor is under State Pension age and is + entitled to Child Benefit. +- Add `widowed_parents_allowance` (Person, YEAR) as a derived + formula reading the WPA rate parameter (which would need + backfilling under `gov/dwp/wpa/rate.yaml`). + +WPA has no new awards, but existing awards continue while the +surviving parent has a dependent child, so some recipients remain. In +the current data they sit inside `bsp_reported` (see above), so they +are already counted in income; the gap is that they can't be told +apart from BSP or reformed separately. + +### Phase 3: data-side bereavement inputs + +Neither `bsp_initial_payment_due` nor `bsp_months_remaining` exists in +the FRS. The FRS is a cross-section, not a panel, and doesn't record +when a partner died, so these inputs would need imputing in the data (Microcosm UK, as the +replacement dataset): + +- start from FRS respondents who report BSP (once it is separated from + the other codes, as above) and from widowed respondents; +- calibrate to DWP's BSP and WPA caseloads ([Stat-Xplore](https://stat-xplore.dwp.gov.uk/)) by age, sex + and region. + +This is the heaviest of the three phases; without it the new +variables work for the household calculator (which can input them +directly) but not for microsim aggregates. + +## Open questions + +- WPA recipients who would have been on BSP under the new rules: do we + want a reform-side parameter "treat all WPA receipts as if they were + BSP" for counterfactual analysis? +- The 2023 Remedial Order extended BSP to cohabiting partners with + children, with effect from 30 August 2018. Eligibility should not + require marriage or civil partnership for deaths from that date where + there are children. + +## References + +- [Pensions Act 2014, Part 5](https://www.legislation.gov.uk/ukpga/2014/19/part/5): primary statute for BSP. +- [Bereavement Support Payment Regulations 2017 (SI 2017/410)][bsp-regs]. +- [Social Security Benefits Up-rating Order 2026 (SI 2026/148), art 19][uprating-2026]: holds the four BSP amounts unchanged. +- [Bereavement Benefits (Remedial) Order 2023 (SI 2023/134)](https://www.legislation.gov.uk/uksi/2023/134/contents): cohabiting-parent extension, effective from 30 August 2018. +- [SSCBA 1992 s. 39A][sscba-39a]: legacy WPA basis. +- gov.uk, [Bereavement Support Payment](https://www.gov.uk/bereavement-support-payment) and [Widowed Parent's Allowance](https://www.gov.uk/widowed-parents-allowance). +- DWP, [Stat-Xplore](https://stat-xplore.dwp.gov.uk/): Bereavement Support Payment and Widowed Parent's Allowance caseloads. +- Issues: [#466](https://github.com/PolicyEngine/policyengine-uk/issues/466) (BSP formula), [#465](https://github.com/PolicyEngine/policyengine-uk/issues/465) (historical WPA). + +[pa-2014-5]: https://www.legislation.gov.uk/ukpga/2014/19/part/5 +[bsp-regs]: https://www.legislation.gov.uk/uksi/2017/410/contents +[bsp-reg-3]: https://www.legislation.gov.uk/uksi/2017/410/regulation/3 +[uprating-2026]: https://www.legislation.gov.uk/uksi/2026/148/article/19 +[sscba-39a]: https://www.legislation.gov.uk/ukpga/1992/4/section/39A diff --git a/docs/book/programs/gov/dwp/cold-weather-payment-plan.md b/docs/book/programs/gov/dwp/cold-weather-payment-plan.md new file mode 100644 index 0000000000..b24fd6315b --- /dev/null +++ b/docs/book/programs/gov/dwp/cold-weather-payment-plan.md @@ -0,0 +1,152 @@ +# Cold Weather Payment (planned) + +```{warning} +**Not yet modelled.** The Cold Weather Payment (CWP) is a DWP payment +of **£25 per 7-day period of very cold weather** to qualifying +households in England and Wales. Northern Ireland runs its own scheme +on the same terms (1 November 2026 to 31 March 2027, +[nidirect](https://www.nidirect.gov.uk/articles/cold-weather-payment)). +PolicyEngine UK doesn't model it. This page sets out the proposed +scope, tracked in +[#435](https://github.com/PolicyEngine/policyengine-uk/issues/435). + +In Scotland the equivalent is the **Winter Heating Payment**, a flat +annual payment (£62.00 from April 2026, regulation 9 of +[SSI 2023/16](https://www.legislation.gov.uk/ssi/2023/16/contents) as +amended), which +replaced Cold Weather Payment there in 2023. It isn't modelled either. +The separate Pension Age Winter Heating Payment, which replaced Winter +Fuel Payment in Scotland, is modelled under +`gov.social_security_scotland.pawhp`. Updated October 2026: amounts, +legislation and the Scotland and Northern Ireland position checked. +``` + +## What CWP is + +Defined in [The Social Fund Cold Weather Payments (General) +Regulations 1988][cwp-1988] (SI 1988/1724). A household qualifies if +**all** of the following hold: + +- The address is in a postcode linked to a weather station where the + average temperature is recorded as, or forecast to be, **0°C or below + over 7 consecutive days** between 1 November and 31 March. +- A member of the benefit unit receives a **qualifying benefit**: + Pension Credit; income-related ESA in the support or work-related + activity group, or with a severe or enhanced disability premium, a + pensioner premium, a disabled child or a child under 5 (the + Regulations extend the same premium test to Income Support and + income-based JSA); Universal Credit when neither the claimant nor a + partner is employed or gainfully self-employed and someone has + limited capability for work or there is a child under 5, or, whether + working or not, when the award includes a disabled child amount; or + Support for Mortgage Interest with one of those premiums or children. + The [GOV.UK eligibility page](https://www.gov.uk/cold-weather-payment/eligibility) + has the exact conditions. + +The payment is £25 per 7-day cold period, and each further cold +period triggers another payment. Spending varies a lot with the winter: in +2024-25, for example, about 1.4 million payments worth around £35 +million were made in England and Wales +([DWP, April 2025](https://www.gov.uk/government/news/over-35-million-in-cold-weather-payments-support-paid-this-winter)). +DWP doesn't count actual payments through the winter; it publishes the +estimated number of eligible people at the start of each season and the +weekly triggers, from which payments can be estimated. + +## Why this is harder than other DWP benefits + +The eligibility test combines an administrative benefit check (clean +for PolicyEngine) with a **geographic weather event** (no FRS +analogue). Modelling the weather side has three plausible approaches: + +1. **Expected-value parameterisation**: a single national + `cold_weather_payments_expected_periods` parameter capturing the + long-run-average number of 7-day cold periods triggered per winter. + Multiply by £25 and by the qualifying household flag to produce the expected + annual payment. Easy to implement, accurate in aggregate, wrong in + any specific winter. +2. **Regional weather mixing**: a per-region weather-event + distribution (for example, the North East has more cold periods than London on + average), parameterised from Met Office historic data. Captures the + distributional skew without forecasting any particular winter. +3. **Year-specific lookups**: tabulate the DWP-published *Cold Weather + Payment statistics* by region by year and use those as historical + inputs. Most accurate for back-cast simulations; needs ongoing + updating. + +Recommendation: **option 2 (regional mixing)** for prospective analysis +and option 3 for back-casts where the data is available. + +## Proposed scope + +### Phase 1: qualifying-benefit gate and expected-value payment + +- New parameter `gov.dwp.cold_weather_payment.amount` = £25. The amount + was lower in earlier years, so take any backdated values from the + amendment history of SI 1988/1724. +- New parameter + `gov.dwp.cold_weather_payment.expected_periods_per_year` = + long-run national average from DWP CWP statistics. +- New variable `cold_weather_payment_eligible` (BenUnit, YEAR) that + fires if any benefit unit member receives one of the qualifying benefits + with the relevant addition / age trigger. +- New variable `cold_weather_payment` (BenUnit, YEAR) = `amount` × + `expected_periods_per_year` × `eligible`. + +This delivers a model that gets the policy-relevant slice right +(*who* qualifies for CWP and how it interacts with their other +benefits) at the cost of smoothing year-to-year weather variance. + +### Phase 2: regional weather mixing + +Add a `gov.dwp.cold_weather_payment.expected_periods_by_region` table +parameterised from Met Office observation data, and a household-side +lookup by `region`. + +### Phase 3: Scotland's Winter Heating Payment + +Paid by Social Security Scotland under +[The Winter Heating Assistance (Low Income) (Scotland) Regulations 2023](https://www.legislation.gov.uk/ssi/2023/16/contents) +(SSI 2023/16). The qualifying benefits are essentially the same as for +Cold Weather Payment (Pension Credit with no further condition; the +other benefits with the same disability, pensioner-premium or +child-under-5 conditions), assessed in a qualifying week (2 to 8 +November in 2026), but the payment is a flat annual amount with no +weather trigger. So: + +- `winter_heating_payment_eligible`: the Phase 1 eligibility rule plus + residence in Scotland. Reuse the Phase 1 variable rather than a second + copy of the benefit conditions. +- `winter_heating_payment` = `amount` × eligible, with `amount` + uprated each year (£62.00 from April 2026). + +## Data needs + +- DWP, [Cold Weather Payment statistics](https://www.gov.uk/government/collections/social-fund-cold-weather-payments): estimated eligible numbers at the start of each season and weekly triggers by weather station. The main calibration source. +- Social Security Scotland's Winter Heating Payment statistics: Phase 3 calibration. +- Met Office, [UK climate historic stations](https://www.metoffice.gov.uk/research/climate/maps-and-data/historic-station-data): Phase 2 regional expected-period parameterisation. + +The FRS gives `region` cleanly, so the geographic gate is no extra +data work for Phases 1 and 2. + +## Open questions + +- The qualifying-benefit list is narrower than for the Warm Home + Discount in England and Wales, which now covers everyone on + Universal Credit, Housing Benefit, income-related ESA or Pension + Credit. Model the eligibility as one composite variable, shared with + the Scottish Winter Heating Payment, or as separate flags by route + (Universal Credit, legacy benefits, Pension Credit)? +- Spending is small next to most modelled benefits, but the payment + matters to the low-income pensioners and disabled households it + targets. Phase 1 is worth doing even if Phase 2 doesn't follow. + +## References + +- [The Social Fund Cold Weather Payments (General) Regulations 1988 (SI 1988/1724)][cwp-1988]. +- gov.uk, [Cold Weather Payment](https://www.gov.uk/cold-weather-payment). +- DWP, [Cold Weather Payment statistics](https://www.gov.uk/government/collections/social-fund-cold-weather-payments). +- Scottish equivalent: [The Winter Heating Assistance (Low Income) (Scotland) Regulations 2023 (SSI 2023/16)](https://www.legislation.gov.uk/ssi/2023/16/contents); [mygov.scot](https://www.mygov.scot/winter-heating-payment). +- Northern Ireland: [nidirect, Cold Weather Payment](https://www.nidirect.gov.uk/articles/cold-weather-payment). +- Issue: [#435](https://github.com/PolicyEngine/policyengine-uk/issues/435). + +[cwp-1988]: https://www.legislation.gov.uk/uksi/1988/1724 diff --git a/docs/book/programs/gov/dwp/pip-points-plan.md b/docs/book/programs/gov/dwp/pip-points-plan.md new file mode 100644 index 0000000000..67a20e8091 --- /dev/null +++ b/docs/book/programs/gov/dwp/pip-points-plan.md @@ -0,0 +1,171 @@ +# PIP point system (planned) + +```{warning} +**Not yet modelled.** PolicyEngine UK currently takes the PIP daily-living +and mobility **band** as a categorical input (`pip_dl_category` and +`pip_m_category`, each `STANDARD | ENHANCED | NONE`) and multiplies by +the published weekly rate. The **points-system** logic that determines +which band a claimant falls into (ten daily-living activities and two +mobility activities, with descriptors worth 0 to 12 points under the +legislation) is **not** modelled. +This page captures the proposed scope, tracked under +[#1069](https://github.com/PolicyEngine/policyengine-uk/issues/1069). +``` + +```{admonition} Status, October 2026 +A first step is open as +[#1661](https://github.com/PolicyEngine/policyengine-uk/pull/1661): total +points inputs (`pip_dl_points`, `pip_m_points`) and the 8- and 12-point +thresholds under `gov.dwp.pip.{daily_living,mobility}.points_thresholds`, +with the category derived from points only when it is not given as an +input. That is a simpler form of Phase 1 below: no per-activity scores +and no `use_point_system` switch, since a category input already takes +precedence over the formula. + +PR #1661 is waiting on a data-side path. Its companion, +[policyengine-uk-data #408](https://github.com/PolicyEngine/policyengine-uk-data/pull/408) +(points imputed from the observed categories), was closed in June 2026 +in the uk-data archive clean-up, with PIP points derivation to move into +Microcosm UK's source stages +([microcosm #145](https://github.com/PolicyEngine/microcosm/issues/145)) +once the input variables exist here. +``` + +## What PIP scoring actually is + +The Personal Independence Payment is awarded on a **points-based** +assessment defined in Schedule 1 to [SI 2013/377][si-377] (the Social +Security (Personal Independence Payment) Regulations 2013): + +- **Part 2: Daily living activities**: ten activities (preparing food, + taking nutrition, managing therapy, washing and bathing, managing + toilet needs, dressing and undressing, communicating verbally, + reading and understanding signs and symbols, engaging with other + people face-to-face, making budgeting decisions). Each activity has + a set of descriptors with point values from 0 up to 12; the top + value varies by activity. +- **Part 3: Mobility activities**: two activities (planning and + following a journey, moving around), each with descriptors worth up + to 12 points. + +The award rule: + +| Total points (per component) | Award | +|-----------------------------|-------| +| 0–7 | None | +| 8–11 | Standard rate | +| 12+ | Enhanced rate | + +Daily living and mobility are scored independently, so a claimant can be +on standard daily-living and enhanced mobility, or any other +combination. + +## Why the current categorical model is reasonable for microsim + +For population-aggregate simulation, the categorical bands are +sufficient because: + +- the FRS doesn't capture activity-level point scores at all, +- DWP's published caseload reports band counts (standard / enhanced) + rather than activity scores, +- the data (both the certified Enhanced FRS from policyengine-uk-data + and Microcosm UK, which is being built to replace it) sets + `pip_dl_category` and `pip_m_category` by comparing the weekly PIP daily-living and mobility + amounts respondents report in the FRS with the standard and enhanced + rates. + +So for headline distributional and fiscal analysis the categorical +inputs are correct. + +## Where the point system would matter + +Three reform scenarios that the categorical model can't answer: + +1. **Activity-level descriptor reform**: for example, the April 2024 + green paper consulted on changing the descriptors, and the March 2025 *Pathways to + Work* green paper proposed requiring at least four points in one + daily-living activity for the daily-living component. The government + withdrew the four-point change in July 2025, pending a review of the + PIP assessment. A categorical input can't capture + this; the categorical band wouldn't shift unless an analyst manually + re-bands a synthetic distribution. +2. **Threshold reform**: changing the band thresholds (for example raising + the standard-rate floor from 8 to 10 points). Same limitation. +3. **Targeted analysis by descriptor**: distributional questions like + "how does the enhanced cohort split between mobility activities" need + the underlying activity scores. + +## Proposed scope + +### Phase 1: points-to-category mapping + +- Add the threshold parameters under `gov/dwp/pip/`: + - `daily_living/standard_threshold.yaml` (8) + - `daily_living/enhanced_threshold.yaml` (12) + - `mobility/standard_threshold.yaml` (8) + - `mobility/enhanced_threshold.yaml` (12) +- Add per-activity score inputs as `pip_dl_activity_1_score` ... + `pip_dl_activity_10_score`, `pip_m_activity_1_score`, + `pip_m_activity_2_score`. Type: integer (Person, YEAR), default 0. +- Add derived variables `pip_dl_total_score` and `pip_m_total_score` + summing the activity scores. +- Add `pip_dl_category_from_points` and `pip_m_category_from_points` + that look up the band from the total score against the threshold + parameters. +- Keep `pip_dl_category` / `pip_m_category` as the **input** the rest of + the model reads, with a new switch parameter + `gov.dwp.pip.use_point_system` (default `false`) that, when `true`, + overrides the categorical input with the points-derived band. + +This is a strict superset of the current model: the categorical input +remains authoritative by default, and analysts opting into the points +machinery (for reform analysis) get the activity-level surface. + +### Phase 2: descriptor-level parameters + +- Per-activity descriptor tables under `gov/dwp/pip/descriptors/`, + encoding the point value of each descriptor in SI 2013/377. The + representation can be flat (one YAML per activity with a list of + `(descriptor_id, points)` entries). +- A `pip_dl_activity_1_descriptor` (etc.) enum input that selects which + descriptor applies; the activity score is then a parameter lookup. +- Reform-side: descriptor point values are then parameters that can be + perturbed for "what if descriptor X scored 4 instead of 6" analyses. + +### Phase 3: data-side imputation + +- The FRS provides PIP payment amounts but not descriptor selections. + The data (Microcosm UK, as the replacement dataset) would need to + impute descriptor selections per claimant that are consistent with the observed band. The plausible + approach is to draw uniformly across descriptor combinations that + sum to the observed band, weighted by DWP-published "activities most + commonly scoring above zero" tables. +- This phase is the heaviest and is only required to make Phase 2 + reforms work on the calibrated microsim. For the household + calculator, users can input activity scores directly. + +## Open questions + +- Should the score inputs default to `None` (sentinel) or 0? Sentinel + is cleaner when the points machinery is opt-in via the + `use_point_system` flag. +- DWP's PIP review process distinguishes "ongoing" vs "fixed-term" + awards. Should the model expose the award duration, or treat all + current awards as ongoing? (Current model assumes ongoing.) +- The outcome of the PIP assessment review may change descriptors or + the scoring rule; the descriptor-parameter design in Phase 2 should + make such changes a parameter edit. + +## References + +- [The Social Security (Personal Independence Payment) Regulations 2013 (SI 2013/377)][si-377]: Schedule 1, Parts 2 and 3 define the point system. +- DWP, [Personal Independence Payment: Assessment guide][assess-guide]. +- DWP, [PIP statistics](https://www.gov.uk/government/collections/personal-independence-payment-statistics): caseload by component and band. +- DWP, [Modernising support for independent living: the health and disability green paper (April 2024)][green-paper]: proposals on the descriptors. +- DWP, [Pathways to Work: Reforming Benefits and Support to Get Britain Working (March 2025)](https://www.gov.uk/government/consultations/pathways-to-work-reforming-benefits-and-support-to-get-britain-working-green-paper): the four-point proposal. +- Issue: [#1069](https://github.com/PolicyEngine/policyengine-uk/issues/1069). +- Existing model: `pip_dl_category`, `pip_m_category`, `pip_dl`, `pip_m`. + +[si-377]: https://www.legislation.gov.uk/uksi/2013/377 +[assess-guide]: https://www.gov.uk/government/publications/personal-independence-payment-assessment-guide-for-assessment-providers +[green-paper]: https://www.gov.uk/government/consultations/modernising-support-for-independent-living-the-health-and-disability-green-paper diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index e4c03f4039..0430650f94 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -26,20 +26,54 @@ Three variables under `gov/dwp/`: current law but tracked separately in PolicyEngine so the reform surface stays clean. - Both are the reported weekly amount above the type's full rate in the - data year, `max(reported - flat_max_for_type, 0)`, uprated as described - below and multiplied by `WEEKS_IN_YEAR`. + data year, `max(reported - flat_max_for_type, 0)`, uprated by September + CPI as described below and multiplied by `WEEKS_IN_YEAR`. All three split the amount a person reported in the data year (the dataset's first year) by the person's `state_pension_type` in the year -simulated. The part up to that type's full rate in the data year is basic -or new State Pension, and the part above it is additional State Pension. -Both parts are uprated by the type's full rate (the year's over the data -year's), so together they pay the reported amount uprated by the flat rate, -for anyone over State Pension age. In law, additional pensions and -protected payments rise with prices instead: the Social Security Benefits -Up-rating Order 2026 raised them by 3.8% in April 2026 (articles 4(3) and -6(3)), and the full rates by 4.8%. This is tracked in -[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941). +simulated, for anyone over State Pension age: + +- The part up to that type's full rate in the data year is basic or new + State Pension. It is uprated by the type's full rate: the year's over the + data year's. +- The part above it is additional State Pension. It is uprated by September + CPI: `gov.economic_assumptions.indices.september_cpi_uprating` in the year + over the data year. + +That follows the law. Each year's Up-rating Order applies the triple lock to +the full rates; the statutory minimum is earnings growth +([Social Security Administration Act 1992 s.150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A)). The Order raises +additional pensions (s.150(1)(c)), increments for deferring the pension and +the amounts of new State Pension above the full rate (protected payments, +s.151A) by the September CPI of the year before. In April 2026 the full +rates rose 4.8% and the rest 3.8% +([Social Security Benefits Up-rating Order 2026](https://www.legislation.gov.uk/uksi/2026/148/made), +articles 4(2), 4(3), 6(1) and 6(3)); in April 2025, 4.1% and 1.7% +([2025 Order](https://www.legislation.gov.uk/uksi/2025/295/made), the same +articles). Graduated retirement benefit, paid with the pension, also rose by +September CPI (article 11 of each Order). Until the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941) the +model uprated the part above the full rate by the full rate too. + +For the years the Up-rating Orders cover (to April 2026), the model +reproduces each Order's percentage exactly. For later years the Act sets a +floor, not a rate: the Order must raise additional pensions "by a percentage +not less than" the rise in prices (s.150(2)), and protected payments by no +less (s.151A(3)). The model pays exactly September CPI, the floor and what +every Order has given. A reform that raises the part above the full rate by +more has no parameter to set; it needs a reform to this variable. + +One simplification remains: additional pension inside a reported total +below the full basic rate is not separated, so it is uprated with the full +rate (as `overlapping_state_pension` also notes). + +So a reform to the full rates, or to the triple lock, moves only basic and +new State Pension. A reform that sets a full rate to zero leaves additional +pensions and protected payments in place; `gov.contrib.abolish_state_pension` +removes all State Pension. The September CPI index is read from the +simulation's own parameters, so a scenario applied before the data load that +changes September CPI, or the calendar-year CPI growth its forecast follows, +moves the part above the full rate. Survey ages are held fixed in the years a dataset is projected to, so a record's birth cohort moves one year later for each year projected, and its @@ -167,6 +201,19 @@ to 2026-27; later years are uprated by `gov.economic_assumptions.indices.triple_lock`, built from the yearly rates in `gov.economic_assumptions.yoy_growth.triple_lock`. +The part of a reported State Pension above the full rate is uprated by +`gov.economic_assumptions.indices.september_cpi_uprating` instead (see +Components). Its yearly rises, +`gov.economic_assumptions.yoy_growth.september_cpi_uprating`, are the +September CPI of the year before from `statutory_uprating_inputs.cpi_september` +(below), rounded to the 0.1 percentage points the ONS publishes, and zero +when prices fall (`create_september_cpi_uprating.py`). They equal the rise +each Up-rating Order gave additional pensions from April 2011 to April 2026, +including none in April 2016, when September 2015 CPI was -0.1% +(`policyengine_uk/tests/test_state_pension_components.py`). The index is +rounded to five decimal places each year, so it differs from the exact +product of the rises by less than 0.001%. + ### The triple lock The rise each April is the highest of: @@ -338,6 +385,13 @@ abroad. The model also includes Northern Ireland, which DWP's figures do not, so the like-for-like gap is larger by Northern Ireland's State Pension. +These runs predate the fix for +[#1941](https://github.com/PolicyEngine/policyengine-uk/issues/1941), which +uprates additional pensions and protected payments by September CPI instead of +the triple lock. The fix leaves the data year unchanged. In projected years, +when the full rates rise faster than prices, it lowers the model's State +Pension and widens the gap to the forecasts in this table. + **The data year.** In 2024-25 the model has 11.36m State Pension recipients averaging £201.6 a week, for the UK. The FRS's own grossed figures for Great Britain are 11.53m recipients averaging £212 a week, and DWP's administrative @@ -362,10 +416,14 @@ tracked in [#1929](https://github.com/PolicyEngine/policyengine-uk/issues/1929): population keeps the data year's age structure. From 2025-26 to 2030-31 the model's State Pension recipients fall 3.0%, as State Pension age rises to 67, while DWP's caseload less those paid abroad rises 4.8%. -- **Awards.** Each record keeps its reported amount, uprated by the flat - rate, including records whose cohort moves from basic to new State - Pension. The model's State Pension per recipient grows 17.0% over those - years. DWP's spending per recipient, less those paid abroad, grows 18.3%, +- **Awards.** Each record keeps its reported amount, the part up to the + full rate uprated by the flat rate and the part above it by September CPI, + including records whose cohort moves from basic to new State + Pension. In the runs behind the table above, which predate the fix for + #1941 and uprated the part above the full rate by the flat rate too, the + model's State Pension per recipient grew 17.0% over those years; uprating + that part by September CPI makes it grow more slowly. DWP's spending per + recipient, less those paid abroad, grows 18.3%, as its new State Pension spending grows from £56.1bn to £102.4bn and its basic State Pension spending falls from £66.7bn to £57.8bn. @@ -384,6 +442,7 @@ part of the widening. | `new_state_pension` returned flat max for every NEW retiree, ignoring partial NI records | Fixed | #1634 | | Protected Payment only computed for BASIC-type recipients | Fixed | #1634 (ASP now extends to NEW) | | `additional_state_pension` split the reported amount by the data year's State Pension type, paying part of it twice for records moving from basic to new cohorts | Fixed | #1921 | +| `additional_state_pension` uprated additional pensions and protected payments by the full rate (the triple lock), not September CPI | Fixed | #1941 | ### What's still open diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 5a6a07caff..c254e52dc8 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -38,7 +38,19 @@ "* Limited Capability for Work and Work-Related Activity amount\n", "* Carer amount\n", "\n", - "The table below shows some of the rates covered by PolicyEngine-UK. " + "The table below shows some of the rates covered by PolicyEngine-UK. \n", + "\n", + "## Allowable employment expenses\n", + "\n", + "The `employment_expenses` input is the annual amount already allowed under [Chapter 2 of Part 5 of ITEPA](https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2). Universal Credit disregards this amount from each person's employed earnings before the minimum income floor, under [GB regulation 55(3)(a)](https://www.legislation.gov.uk/uksi/2013/376/regulation/55) and the corresponding [Northern Ireland regulation 55(3)(a)](https://www.legislation.gov.uk/nisr/2016/216/regulation/55). The existing income-tax expense deduction also uses this input.\n", + "\n", + "Supply the legally allowed amount after applicable limits for the relevant employment and other deductions; do not supply all raw work costs. Expenses paid on an employee's behalf and reimbursements must meet sections 333 and 334, including the required inclusion in gross earnings. The same costs must not be deducted twice. The person-level annual input does not determine admissibility, job-by-job limits, or special-case exceptions. No aggregate salary cap or new earnings floor is inferred from it.\n", + "\n", + "For example, in the existing 2024 annual model, a single claimant aged 30 with £6,000 employment income and £600 of allowable expenses has £5,400 UC earnings before the minimum income floor. With no tax, NI, pension deductions, work allowance or other award adjustments, the 55% taper increases final UC by £330 relative to the same worker with no expenses. This illustrates the UC award; it does not certify household net-income treatment of the cost itself.\n", + "\n", + "Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified.\n", + "\n", + "This partial correction leaves payroll giving and other unresolved parts of [issue 1997](https://github.com/PolicyEngine/policyengine-uk/issues/1997) open. It retains the existing annual allocation, pension/trade calculation, minimum income floor, claimant selection and direct-input overrides. It does not establish assessment-period expense/payment timing, special-category admissibility, historical commencement or complete GB/NI history.\n" ] }, { diff --git a/docs/book/programs/gov/hmrc/firm-vat.md b/docs/book/programs/gov/hmrc/firm-vat.md new file mode 100644 index 0000000000..9ae9e79977 --- /dev/null +++ b/docs/book/programs/gov/hmrc/firm-vat.md @@ -0,0 +1,203 @@ +# Firm-level VAT (planned) + +```{warning} +**Not yet modelled.** PolicyEngine UK models VAT only on *household +consumption*: the `vat` variable applies `gov.hmrc.vat.standard_rate` and +`gov.hmrc.vat.reduced_rate` to each household's standard- and reduced-rate +spending (`full_rate_vat_consumption`, `reduced_rate_vat_consumption`), then +grosses the result up to VAT receipts. There is no business side. This page +sets out the scope for adding firm-level VAT and a `Firm` entity, tracked in +[#1320](https://github.com/PolicyEngine/policyengine-uk/issues/1320) and +[#1333](https://github.com/PolicyEngine/policyengine-uk/issues/1333). Both +issues are still open (October 2026). +``` + +## Why firm-level VAT? + +Household-side VAT in PolicyEngine answers the question "what does this +household pay in VAT on the goods and services it buys?" but it can't +answer policy questions framed at the business side: + +- How does changing the VAT registration threshold (£90,000 of taxable + turnover since 1 April 2024) affect the number of firms in scope, and through + them the consumer prices that households face? +- What's the revenue effect of moving a supply between standard, reduced + and zero rates seen from the *supplier* side (input/output netting)? +- How does the flat-rate scheme distort the modelled compliance picture? + +Closing those questions needs a separate entity that *carries* turnover, +sector, and registration status, and variables for input/output VAT and +net liability. + +## Scope + +### Phase 1: minimum viable product + +1. **`Firm` entity** with the bare attributes needed for the threshold + and rate questions: `firm_turnover`, `firm_industry_sector`, + `firm_vat_registered`. Firms are linked to households for sole + traders and partnerships (unincorporated business income flows + already exist in the FRS) and stand alone for incorporated entities. +2. **Registration threshold logic**: a firm must register if its taxable + turnover is more than a new `gov.hmrc.vat.firm.registration_threshold` + parameter, or it can register voluntarily. The threshold has been + £90,000 since 1 April 2024. +3. **Standard / reduced / zero-rated supply split** at the firm level, + driven by sector-specific share parameters. +4. **`firm_vat_on_sales`** (output VAT) and **`firm_vat_on_purchases`** + (input VAT), each computed from share-based decompositions of + turnover and intermediate consumption. +5. **`firm_net_vat_liability`** = output VAT − input VAT. + +### Phase 2: distributional refinements + +- **Flat-rate scheme** for small businesses (a new + `gov.hmrc.vat.firm.flat_rate_scheme` parameter tree). +- **VAT groups** and partial exemption (de minimis rules for + exempt-versus-taxable splits). +- **Cross-border VAT** after Brexit (Northern Ireland under the Windsor + Framework, postponed VAT accounting on imports). + +### Phase 3: incidence + +- **Pass-through** of VAT rate changes to consumer prices, joining up + the household-side `vat` variable and the firm-side + `firm_net_vat_liability` through an explicit incidence assumption + (already modelled in spirit via `vat_change` on the household side; + Phase 3 promotes it to a first-class link). + +## Implementation plan + +### Entity + +A new `Firm` entity in `policyengine_uk/entities.py` next to the +existing `Household`, `BenUnit`, `Person`: + +```python +Firm = build_entity( + key="firm", + plural="firms", + label="Firm", + doc="A business entity that may register for VAT.", + roles=[ + { + "key": "owner", + "plural": "owners", + "label": "Owner", + "doc": "Person or household that owns the firm.", + } + ], + containing_entities=["state"], +) +``` + +The `containing_entities` set deliberately does *not* include +`household` because incorporated firms have no household; the link for +sole traders is via an explicit `firm_owning_household_id` input +variable rather than entity nesting. + +### Variables + +Under a new `variables/gov/hmrc/vat/firm/` tree: + +- `firm_turnover` (input) +- `firm_industry_sector` (input, enum) +- `firm_vat_registered` (derived from turnover and the threshold; can + also be set as input for voluntary registration) +- `firm_standard_rated_share`, `firm_reduced_rated_share`, + `firm_zero_rated_share`, `firm_exempt_share` (derived from sector or + input override) +- `firm_vat_on_sales` (output VAT) +- `firm_vat_on_purchases` (input VAT) +- `firm_net_vat_liability` (output − input) + +The household-side `vat` variable stays untouched; the two sides only +meet in Phase 3. + +### Parameters + +A new tree under `parameters/gov/hmrc/vat/firm/`: + +- `registration_threshold.yaml`: £90,000 from 1 April 2024. +- `deregistration_threshold.yaml`: £88,000 from 1 April 2024. +- Rates: reference the existing `gov/hmrc/vat/standard_rate.yaml` and + `reduced_rate.yaml` rather than duplicating them. Zero-rated supplies + need no rate parameter. +- `sector_supply_split/`: share parameters by industry sector for the + standard/reduced/zero/exempt split (Phase 1.5 refinement; MVP can use + a single national split per sector). +- `flat_rate_scheme/`: Phase 2. + +### Tests + +- Unit tests around the threshold (firm just below, just above, just + above the deregistration threshold). +- Tests of rate-split arithmetic for each sector. +- Integration test asserting that summing `firm_net_vat_liability` + across a representative firm panel reproduces published HMRC VAT + receipts to within calibration tolerance. + +## Data needs + +This is the harder half of the project. Summary of where each Phase 1 +component sits against our current data: + +| Component | Public source | FRS coverage | Datalab source | +|-----------|---------------|--------------|----------------| +| Firm turnover distribution | ONS *Business Demography* | sole-trader / partnership turnover via FRS self-employment income | HMRC Datalab: anonymised VAT returns | +| Industry sector | SIC codes from ONS *Inter-Departmental Business Register (IDBR)* | not in FRS | HMRC Datalab | +| VAT registration share | HMRC *VAT statistics* (annual) | not in FRS | HMRC Datalab | +| Input VAT shares | ONS *Supply and Use tables* | not applicable | HMRC Datalab | + +### Proposed imputation approach + +1. Build a **synthetic firm panel** weighted to ONS Business Demography + counts by SIC sector and turnover band. +2. Attach to each firm a registration status, supply split, and + intermediate-consumption ratio drawn from HMRC VAT statistics and + ONS Supply and Use tables. +3. Calibrate the panel so that summed `firm_net_vat_liability` matches + the HMRC VAT receipts outturn for the year. This mirrors the + second-stage imputation pattern discussed in + [#1621](https://github.com/PolicyEngine/policyengine-uk/issues/1621). +4. For sole traders and partnerships, link the synthetic firm row back + to the FRS household that owns it via self-employment income. + +The companion repo [`policyengine/uk-vatlab`][vatlab] hosts the analysis +files (#1333) used to size each step before they are productionised. + +## Effort estimate + +- Adding the `Firm` entity, `firm_turnover` input, threshold logic, and + a single-rate liability variable: ~1–2 days. +- Phase 1 MVP (full Firm entity, sector-driven supply split, calibrated + synthetic firm panel, threshold/rate tests, integration test against + HMRC outturn): ~3–4 weeks, with most time on the data side. +- Phase 2 (flat-rate scheme, VAT groups, partial exemption): another + 2–3 weeks once the panel is stable. +- Phase 3 (cross-side incidence): tied to the + `consumer_incident_tax_revenue_change` infrastructure already in the + repo; the link itself is small but the elasticity assumptions need + agreement. + +## Open questions + +- Should the synthetic firm panel live in the data pipeline or in this + repo? Recommendation: in the data pipeline + ([Microcosm UK](https://github.com/PolicyEngine/microcosm)), with the + rules variables here. +- Do we need to model Making Tax Digital filing-frequency mechanics, or + is annualised liability sufficient for nowcasting purposes? +- HMRC Datalab access for anonymised VAT returns: pursue, or stick to + ONS / public-aggregate calibration? + +## References + +- HMRC, [VAT rates](https://www.gov.uk/vat-rates) and [VAT registration thresholds](https://www.gov.uk/vat-registration-thresholds). +- [Value Added Tax Act 1994](https://www.legislation.gov.uk/ukpga/1994/23/contents): primary statute. +- HMRC, [VAT statistics](https://www.gov.uk/government/statistics/value-added-tax-vat-annual-statistics): annual outturn used for calibration. +- ONS, [UK Business: Activity, Size and Location](https://www.ons.gov.uk/businessindustryandtrade/business/activitysizeandlocation) (the IDBR-based business demography release). +- ONS, [Supply and Use tables](https://www.ons.gov.uk/economy/nationalaccounts/supplyandusetables): intermediate consumption shares. +- Companion analysis repo: [`policyengine/uk-vatlab`][vatlab] (#1333). + +[vatlab]: https://github.com/policyengine/uk-vatlab diff --git a/docs/book/programs/gov/hmrc/national-insurance-directors.md b/docs/book/programs/gov/hmrc/national-insurance-directors.md index 1f0f8b1281..0eb5db5cbf 100644 --- a/docs/book/programs/gov/hmrc/national-insurance-directors.md +++ b/docs/book/programs/gov/hmrc/national-insurance-directors.md @@ -1,106 +1,100 @@ -# Company-director NI (annual cumulative basis) - -```{note} -PolicyEngine UK applies a **single annual NI calculation** at the -person level. For most users this matches what HMRC charges company -directors at year end. This page explains the relationship between the -director-specific "annual cumulative" basis HMRC uses and PolicyEngine's -current modelling, and describes what would need to change to expose a -dedicated director-specific path. Tracked under -[#721](https://github.com/PolicyEngine/policyengine-uk/issues/721). +# Company-director National Insurance + +## Supported 2022–23 employee schedule + +For model year `2022`, the explicit Person/year input +`is_full_year_category_a_director` selects the employee primary Class 1 +schedule for a person who was a director throughout the tax year, had one +employment and was in category A throughout. The input defaults to `False`; +the model does not infer it from salary, dividends or company ownership. +The input label names its supported 2022–23 period. In other model years, +even an explicit `True` leaves the ordinary calculation unchanged. + +[Regulation 8(3) of the Social Security (Contributions) Regulations +2001][reg8] sets the earnings period to the year for a director of the +company at the beginning of that year. + +HMRC's [2022–23 employer guidance][rates] gives this annual director schedule: + +| Annual NI earnings | Employee rate | +| --- | ---: | +| Up to £11,908 | 0% | +| Above £11,908 up to £50,270 | 12.73% | +| Above £50,270 | 2.73% | + +For example, £30,000 of annual NI earnings produces £2,303.1116 before +monetary rounding: (£30,000 − £11,908) × 12.73%. The model retains its existing +floating-point representation; this is not a new payroll rounding rule. +The director route uses the existing `ni_class_1_income` earnings definition, +including its statutory-pay components, and the existing final `ni_liable` +age gate. Employer NI, income-tax and other NI formulas and rates remain +unchanged. Their outputs need not all be unchanged: the existing Class 4 annual +maximum uses primary Class 1 contributions, so it can change for a director +who also has trading profits. That is propagation through the existing cap, +not a new Class 4 rule or a certification of all mixed-income cases. + +A policy-test input for this restricted case is: + +```yaml +period: 2022 +input: + age: 40 + employment_income: 30000 + is_full_year_category_a_director: true +output: + ni_class_1_employee: 2303.1116 ``` -## What HMRC's annual cumulative basis is - -Class 1 NI is normally charged on each *earnings period* (typically a -week or a month) — the employer applies the weekly/monthly thresholds -to that pay period's earnings in isolation. Directors are a special -case because their pay is often irregular: a director might take £0 in -salary for eleven months and then a £200,000 bonus in December. Under -period-by-period NI rules, that pattern would generate massive NI in -the bonus month and almost none in the other eleven, even though the -director's annual income is no different from someone earning a steady -salary. - -To prevent this avoidance route, [SSCBA 1992 s.122 and the *Social -Security (Contributions) Regulations 2001*][nim12000] require directors -to use an **annual earnings period** by default: - -1. The director's NI is computed on a **cumulative** year-to-date - basis. -2. The annual thresholds (Primary Threshold, Upper Earnings Limit, - Secondary Threshold) are applied to YTD earnings, not to each pay - period's earnings in isolation. -3. NI is charged once YTD earnings cross each threshold, and never - refunded if earnings drop later in the year. - -There is also an "**alternative**" method, which approximates the -annual basis with monthly cumulative figures, used for in-year -operational simplicity; the final year-end reconciliation still uses -the annual basis. - -## How PolicyEngine currently handles this - -PolicyEngine UK already computes NI **annually** for every person: - -- The Class 1 employee primary and additional formulas in - [`variables/gov/hmrc/national_insurance/class_1/`](https://github.com/PolicyEngine/policyengine-uk/tree/main/policyengine_uk/variables/gov/hmrc/national_insurance/class_1) - read weekly thresholds from `gov.hmrc.national_insurance.class_1.thresholds` - and convert them to monthly (`× WEEKS_IN_YEAR / MONTHS_IN_YEAR`) before - applying the rate. -- Because the input `employment_income` is annual and is implicitly - spread evenly across months, the **annual NI** the model produces is - identical to the annual cumulative figure a director would owe — for - someone earning the same total over the year, the two methods give - the same answer. - -That's why microsimulation results don't depend on director status. - -## Where the current model could diverge from HMRC's director rules - -Three situations could in principle produce a different answer: - -1. **Irregular within-year pay**. If a household-calculator user inputs - an annual `employment_income` of, say, £80,000 but means it as a - single lump-sum payment, PolicyEngine still spreads it across twelve - months — matching the annual cumulative outcome. There is no - surface here for the user to express the pay timing. -2. **Mid-year start or end** of directorship within a tax year. The - pro-rating rules for directors who start or cease being a director - part-way through a year are slightly different from regular - employees — PolicyEngine assumes a full-year period everywhere. -3. **Multiple employments**. A director with two separate company - roles can sometimes have NI calculated separately per employment. - PolicyEngine has a single `employment_income` per person and so - collapses this case. - -In practice (1) and (2) only matter for the household-calculator -front-end, not microsimulation. (3) is a wider modelling gap that -applies beyond directors. - -## What a dedicated director path would look like - -A future PR addressing this fully would add: - -- A new boolean input `is_company_director` (Person, year). -- A documentation flag on `ni_class_1_employee_primary` / `_additional` - noting that for directors with `is_company_director == true` the - formula returns the same annual figure as the regular path (rather - than period-by-period NI), confirming the equivalence. -- Optional follow-up: a `company_director_pay_timing` enum so that - household-calculator users who model a director's actual pay pattern - can document their intent, but the resulting NI stays on the annual - basis. - -For now PolicyEngine is **functionally correct** for any user whose -input is an annual income — which is the expected interface — and -no code changes are needed in the short term. +The primary and additional component variables allocate their respective +annual liabilities equally over twelve model months. Adding the months +recovers the annual liability. These allocations are **not** statutory +interim payroll deductions, observed monthly cash, cumulative year-to-date +charges or a model of bonus timing. The annual earnings interface does not +represent when pay was received. + +## Why this differs from the ordinary route + +The ordinary employee route reads weekly thresholds and converts them to +monthly equivalents. Existing fiscal parameter processing samples 30 April: +model year `2022` therefore uses the ordinary employee rate and threshold +in force on 30 April 2022. That convention does not reproduce the distinct +annual director schedule above. Annual salary inputs alone do not make the +two routes equivalent for 2022–23. + +The new director parameters store annual thresholds, with raw dates starting +6 April 2022. A separate implementation-coverage parameter is true only for +2022–23, mapping to model year `2022` under that same fiscal convention. +Its false value from 6 April 2023 limits this implementation; it does not +mean that director law ended then. No ordinary rate or threshold history is +changed. Broader fiscal-year representation remains separate work under +[#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807). + +## Remaining limits + +HMRC describes a [standard annual method and an alternative method][directors]. +The alternative method uses pay-period calculations during the year and +reconciles to the annual amount at year end. This page and the model's new +route concern the supported full-year annual liability, not either method's +in-year payment sequence. + +Appointment years, category changes, multiple employments, other years and +employer-side director NI remain outside this implementation. The separate +employer rate and threshold in HMRC's table are not employee inputs. An +explicit contribution output supplied by a user retains the existing input +override contract; the new fact does not validate that supplied amount. + +This is a partial response to +[#721](https://github.com/PolicyEngine/policyengine-uk/issues/721), which +remains open. It introduces no director imputation, population estimate or +encoded policy-parity claim. ## References -- HMRC, [NIM12000 — Class 1 NICs: company directors](https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000) — primary internal guidance covering the annual cumulative basis. -- HMRC, [CA44 — National Insurance for company directors](https://www.gov.uk/government/publications/ca44-national-insurance-for-company-directors) — the operational employer guide. -- [The Social Security (Contributions) Regulations 2001 (SI 2001/1004) reg. 8](https://www.legislation.gov.uk/uksi/2001/1004/regulation/8) — the regulation that imposes the annual earnings period. -- [Social Security Contributions and Benefits Act 1992 s. 8](https://www.legislation.gov.uk/ukpga/1992/4/section/8) — primary statute for Class 1 employee NI. +- [Social Security (Contributions) Regulations 2001, regulation 8][reg8] — earnings periods for directors. +- [Social Security Contributions and Benefits Act 1992, section 8](https://www.legislation.gov.uk/ukpga/1992/4/section/8) — calculation of primary Class 1 contributions. +- [HMRC NIM12000 — Class 1 NICs: company directors](https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000). +- [HMRC CA44 — National Insurance for company directors](https://www.gov.uk/government/publications/ca44-national-insurance-for-company-directors). -[nim12000]: https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim12000 +[rates]: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 +[directors]: https://www.gov.uk/employee-directors +[reg8]: https://www.legislation.gov.uk/uksi/2001/1004/regulation/8 diff --git a/docs/book/programs/gov/hmrc/national-insurance-scales.md b/docs/book/programs/gov/hmrc/national-insurance-scales.md new file mode 100644 index 0000000000..4d8d8b3769 --- /dev/null +++ b/docs/book/programs/gov/hmrc/national-insurance-scales.md @@ -0,0 +1,93 @@ +# National Insurance parameter scales + +PolicyEngine UK stores three marginal National Insurance (NI) schedules as +parameter scales. Each schedule holds its contribution rates and the thresholds +at which they apply. The schedules retain their dated values and units. + +All paths below start with `gov.hmrc.national_insurance.`. Bracket indices start +at zero. Bracket zero supplies the fixed zero-threshold, zero-rate sentinel. +Contribution reforms use brackets 1 and 2 below. Any nonzero, missing or null +threshold or rate in bracket zero is rejected with a targeted error naming the +canonical leaf. Validation covers its full dated history, including future +entries; an explicit update to zero leaves calculations unchanged. + +| Scale | Contribution bands | Threshold period in the parameter files | +| --- | --- | --- | +| `class_1.employee_scale` | Primary rate in bracket 1; additional rate in bracket 2 | Week | +| `class_1.employer_scale` | Employer rate in bracket 1 | Week | +| `class_4.contribution_scale` | Main rate in bracket 1; additional rate in bracket 2 | Year | + +The contribution formulas retain their period conversions. Changing the +parameter layout does not make a weekly threshold an annual amount. Class 2's +flat contribution amount and its eligibility thresholds remain scalar +parameters. + +## Reform paths + +Use the following canonical paths when specifying new rate or threshold reforms. +The old scalar paths remain live views of the corresponding scale components. +Updating either path changes the same dated parameter history. + +| Existing scalar path | Canonical scale component | +| --- | --- | +| `class_1.rates.employee.main` | `class_1.employee_scale[1].rate` | +| `class_1.rates.employee.additional` | `class_1.employee_scale[2].rate` | +| `class_1.thresholds.primary_threshold` | `class_1.employee_scale[1].threshold` | +| `class_1.thresholds.upper_earnings_limit` | `class_1.employee_scale[2].threshold` | +| `class_1.rates.employer` | `class_1.employer_scale[1].rate` | +| `class_1.thresholds.secondary_threshold` | `class_1.employer_scale[1].threshold` | +| `class_4.rates.main` | `class_4.contribution_scale[1].rate` | +| `class_4.rates.additional` | `class_4.contribution_scale[2].rate` | +| `class_4.thresholds.lower_profits_limit` | `class_4.contribution_scale[1].threshold` | +| `class_4.thresholds.upper_profits_limit` | `class_4.contribution_scale[2].threshold` | + +The bracket syntax above identifies a leaf in a reform path. Python callables +access a raw scale through its `brackets` list, for example +`parameters.gov.hmrc.national_insurance.class_1.employee_scale.brackets[1].rate`. +Existing scalar attribute chains and leaf updates remain supported. The model's +scalar views expose the old names, current dated values, descriptions and units. + +Apply scalar changes through the rate or threshold leaf. The compatibility views +do not provide a second editable copy of the NI schedules. Replacing, deleting or +reparenting legacy subtrees requires an explicit structural migration using the +canonical scales; conflicting legacy replacements raise a migration error. + +## Contribution outputs + +The scales preserve the model's contribution components. Employee Class 1 NI +still adds `ni_class_1_employee_primary` and +`ni_class_1_employee_additional`. These outputs retain their separate input +overrides. Class 4 still calculates its main and additional contributions and +applies the existing annual-maximum rules. Employer NI retains its existing +pension-contribution switch. + +The parameter migration preserves the existing calculation rules, liability +conditions and rounding. + +## Maintaining the schedules + +Edit the canonical scale components when changing the model. Their dated values, +references, units and uprating metadata supply the legacy scalar views. The NI +threshold generator writes to these canonical leaves. Parameter processing visits +each numerical history once; metadata discovery also exposes the legacy names. + +Metadata retains both primitive IDs and their histories and units. Discovery +marks the ten legacy scalar views and the six fixed-zero leaves with +`household: false` and `economy: false`; the ten canonical contribution rate and +threshold leaves remain visible. Direct legacy lookup still resolves the same +canonical metadata and values, while enumeration supplies presentation-only +views. Hiding a fixed-zero control does not change its input-validation rule. + +The pinned, source-extracted API contract tests cover these flags, all other +record fields and update routes. They do not verify a deployed API or the app's +tree, search and control presentation. Consumer owners must check those surfaces +before relying on the migration in a UI. + +The separate full-year Category A director scale and factual-input gate for +2022–23 retain their constrained annual calculation and monthly allocation. +They are not replaced by the ordinary employee schedule. The savings starting +rate remains an annual limit, indexed alongside the five canonical NI threshold +writers without treating it as a weekly amount. + +For NI rate reforms in behavioural scenarios, see +[Labour supply dynamics](../../../usage/dynamics.md). diff --git a/docs/book/programs/gov/hmrc/pension-tax-relief.md b/docs/book/programs/gov/hmrc/pension-tax-relief.md new file mode 100644 index 0000000000..1762ca37f6 --- /dev/null +++ b/docs/book/programs/gov/hmrc/pension-tax-relief.md @@ -0,0 +1,171 @@ +# Pension tax relief + +This page documents how PolicyEngine UK models tax relief on personal +pension contributions and addresses the confusion reported in +[#704](https://github.com/PolicyEngine/policyengine-uk/issues/704) +about whether higher-rate taxpayers receive the right amount of +relief. #704 is still open (October 2026); the figures below were re-run +on policyengine-uk 2.140.0. + +## What the model does + +`pension_contributions_relief` (defined in +[`variables/gov/hmrc/pensions/pension_contributions_relief.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py), +with the limit in `utils/pensions.py`) reduces the person's taxable income +by their pension contributions, up to the annual limit for relief in +[Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190): + +- the person's relevant UK earnings + ([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)), + which the model takes as employment income, plus employer-paid Statutory + Sick Pay and Statutory Maternity Pay (`employment_benefits`, listed as + relevant UK earnings in HMRC's + [PTM044100](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100)), + plus self-employment profit (a trading loss counts as zero, and does not + reduce employment earnings). Statutory Paternity Pay is not yet counted; + open pull request + [#2368](https://github.com/PolicyEngine/policyengine-uk/pull/2368) would + add it to taxable employment pay; +- raised towards `gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount` + (£3,600) where earnings are lower, but only by relief-at-source + contributions ([s. 191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191)). + The model treats `personal_pension_contributions` as relief at source and + employee contributions as a net pay arrangement, which cannot use the + basic amount; +- and only for people under `gov.hmrc.pensions.pension_contributions_relief_age_limit` + (75). + +So someone with no earnings who makes a £3,600 gross contribution to a +personal pension gets a £3,600 `pension_contributions_relief`, while the same +payment as an employee contribution gets none. That £3,600 is the deduction +from taxable income, not a cash top-up. Under relief at source the £3,600 +gross contribution is £2,880 paid by the contributor plus £720 (20%) that the +pension provider claims from HMRC and adds to the pension +([Finance Act 2004 s. 192](https://www.legislation.gov.uk/ukpga/2004/12/section/192)). +PolicyEngine does not represent the £720 claim as a payment, and with no +taxable income the deduction saves no income tax. Statutory Sick Pay and +Statutory Maternity Pay were added to relevant earnings by +[#2464](https://github.com/PolicyEngine/policyengine-uk/pull/2464), merged on +19 October 2026. One open pull request would change this section once merged: +[#2488](https://github.com/PolicyEngine/policyengine-uk/pull/2488) would apply +the same relief-at-source-only rule to the pension uplift in adjusted total +income. + +The annual allowance does not limit the relief. `pension_annual_allowance` +is the standard annual allowance (£60,000 from 6 April 2023). From 2016-17 it +is tapered only when both the statutory adjusted income +(`pension_annual_allowance_adjusted_income`, above £260,000 from 2023-24) and +threshold income (`pension_annual_allowance_threshold_income`, above the +taper level less the standard allowance, £200,000 from 2023-24) are over +their limits, by £1 for every £2 of adjusted income above the taper level, +rounded down to whole pounds, to a minimum of £10,000 +([Finance Act 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA); +parameters under `gov.hmrc.income_tax.allowances.annual_allowance`). See +`docs/engineering/pension-annual-allowance.md` for the income conventions. +Unused allowance carried forward from the previous three tax years +([s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A)) is +the input `unused_pension_annual_allowance`, which is added to the allowance +when the charge is worked out. It defaults to nil, because survey data does +not record past pension input amounts, and the model does not derive it from +earlier years. The money purchase annual allowance, the £10,000 limit on +money purchase savings after pensions are first flexibly accessed +([s. 227ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/227ZA)), +is not modelled. + +Contributions **above** the annual allowance can still be relieved and +separately attract the annual allowance charge, +`personal_pension_contributions_tax`, which is added to income tax. It charges +the excess at the basic, higher and additional rates on the slices that fall +in each band when it is added on top of the person's income after allowances +([Finance Act 2004 s. 227](https://www.legislation.gov.uk/ukpga/2004/12/section/227)). +The amount tested against the allowance is +`pension_contributions_for_annual_allowance`, which counts member +contributions made before age 75, employer contributions and salary +sacrifice ([Finance Act 2004 s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233)). +For example, an employee on £100,000 who pays £70,000 into a personal pension +in 2025-26 gets £70,000 of relief and a £2,000 charge on the £10,000 excess, +which falls in the basic rate band. With £10,000 of unused allowance +brought forward, there is no charge. By default the pension input amount is +cash contributions: defined-benefit accrual counts only if supplied through +`pension_contributions_for_annual_allowance`. + +Relieved contributions also reduce `adjusted_net_income`, so they restore +personal allowance lost above £100,000, as in law +([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)). +An employee on £110,000 has a £7,570 personal allowance in 2025-26, and the +full £12,570 if they pay £10,000 gross into a personal pension. + +So the headline behaviour is: **up to the relief limit, pension +contributions reduce taxable income £1-for-£1**. The income-tax saving +that the model reports is the contribution × the person's marginal +rate, plus any personal allowance restored. + +## Worked examples + +The four scenarios reported in #704 (all 2025-26, all self-employed, +single person aged 40, no other income, contributions entered as +`personal_pension_contributions`). The "model" column was re-run in +October 2026 on policyengine-uk 2.140.0: + +| ID | Self-employed income | Pension contribution | Expected income-tax change | What the model returns | Why | +|-------|---------------------|----------------------|-----------------------------|------------------------|-----| +| 11800 | £30,000 | £0 | baseline | baseline | – | +| 11799 | £30,000 | £3,000 | -£600 | -£600 | £3k contribution × 20% basic rate | +| 11817 | £60,000 | £0 | baseline | baseline | – | +| 11816 | £60,000 | £6,000 | -£2,400 | -£2,400 | £6k contribution × 40% higher rate | + +This is exactly the behaviour reported. The user's expectation that +higher-rate cases should only see -£1,200 (£6k × 20%) corresponds to a +misunderstanding of how UK pension tax relief works in aggregate: the +total tax relief is full marginal rate, even though it is paid in two +parts in the real world (relief at source plus higher-rate top-up via +Self Assessment). + +## Relief at source vs marginal-rate relief + +The user-facing UK pension framework has two parallel mechanisms that +add up to the same total relief: + +1. **Relief at source (RAS)**: the pension provider claims the + basic-rate portion (20%) directly from HMRC. The contributor pays + from net income and the pension grows by the gross amount. For a + basic-rate taxpayer, this is the *entire* relief. +2. **Higher-rate top-up**: for taxpayers at higher or additional + rates, the extra band difference (20% for higher rate, 25% for + additional) is reclaimed via Self Assessment. The mechanism is + technically a **basic-rate-band extension**: HMRC widens the basic + rate band by the gross contribution, pushing income that would + otherwise be taxed at the higher rate down into the basic band. + +PolicyEngine computes the **total** relief (both mechanisms together) +because it operates on annual aggregates rather than walking the +cash-flow steps. That matches HMRC's published statistics on the +*fiscal cost of pension tax relief*, which also report the total +across both mechanisms. + +If you want to see the user-visible tax bill reduction separately from +the pension-provider's RAS claim, you'd need to split the existing +relief into two output variables (a basic-rate-portion and a +higher-rate-top-up portion). This is a presentation question rather +than a calculation question: the totals are unchanged. + +## Marginal tax rate display + +#704 also reported a 0% marginal tax rate where 20% or 40% was +expected. The model's `marginal_tax_rate` variable +([`variables/household/marginal_tax_rate.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/household/marginal_tax_rate.py)) +raises each adult's `employment_income` by +`gov.simulation.marginal_tax_rate_delta` (£1,000, or 0.1% of employment +income where that is larger) and measures the share of the rise that +does not reach `household_net_income`. Re-run in October 2026, it returns +20% for both £30,000 cases and 40% for both £60,000 cases, so the 0% +reported in #704 no longer reproduces in the model. + +## References + +- HMRC, [Tax on your private pension contributions: Tax relief](https://www.gov.uk/tax-on-your-private-pension/pension-tax-relief): user-facing guide to relief at source and higher-rate relief. +- [Finance Act 2004 s. 188-194](https://www.legislation.gov.uk/ukpga/2004/12/section/188): primary statute for relief at source. +- [Finance Act 2004 s. 227](https://www.legislation.gov.uk/ukpga/2004/12/section/227): annual allowance excess charge. +- HMRC, [Personal pensions statistics](https://www.gov.uk/government/collections/personal-pensions-statistics): including the published cost of pension tax relief. +- [Finance Act 2004 s. 190](https://www.legislation.gov.uk/ukpga/2004/12/section/190) and [s. 191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191): the earnings-or-£3,600 limit on relief, with the £3,600 basic amount for relief at source only. +- Model variables: [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). diff --git a/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md new file mode 100644 index 0000000000..67ce942385 --- /dev/null +++ b/docs/book/programs/gov/hmrc/pensions-comparison-stbm.md @@ -0,0 +1,113 @@ +# Pension treatment compared with the Scottish Tax-Benefit Model + +This page compares how PolicyEngine UK and the +[Scottish Tax-Benefit Model (STBM)][stbm] treat personal pension +contributions in income tax, for +[#672](https://github.com/PolicyEngine/policyengine-uk/issues/672) (still open, +October 2026). + +The STBM column is taken from its source code at commit +[`f534ca1`][stbm-it] (`src/IncomeTaxCalculations.jl`, +`calculate_pension_taxation!`, and `src/Pensions.jl`). The +[STBM blog post on pension contributions][stbm-blog] that #672 links to is a +short note, and its author says they are unsure of it. It does not describe +the method, so this page does not rely on it. + +[How PolicyEngine UK models pension tax relief](./pension-tax-relief.md) has +more detail on the PolicyEngine side. + +## Summary + +| | PolicyEngine UK | STBM | +|---|---|---| +| How relief is given | Reduces taxable income by the contribution (`pension_contributions_relief`) | Treats contributions as paid net of basic rate: grosses them up by 1/(1 − basic rate) and widens every rate band above the starting band by the gross amount, as under relief at source | +| Contributions that get relief | The person's own contributions | Employee contributions, additional voluntary contributions **and employer contributions** | +| Basic-rate relief added to the pot | Not shown separately | Recorded separately (`pension_relief_at_source`) | +| Earnings limit | Relief capped at relevant UK earnings: employment income, employer-paid Statutory Sick Pay and Statutory Maternity Pay, and self-employment profit | No earnings cap; £3,600 limit when total income is below it | +| Annual allowance | £60,000 (`pension_annual_allowance`), tapered only when adjusted income is above £260,000 and threshold income is above £200,000, as in FA 2004 s. 228ZA | Tapered on total income above a threshold-income parameter | +| Contributions above the allowance | Annual allowance charge on the excess, taxed as the top slice of income (`personal_pension_contributions_tax`) | Relief stops at the allowance; no separate charge | +| Employer contributions in the data | Read from the data (`employer_pension_contributions`) | Imputed when missing from the FRS, using the ONS ASHE employer-contribution bands (`src/Pensions.jl`) | +| Carry-forward of unused allowance | Input `unused_pension_annual_allowance` (FA 2004 s. 228A) adds to the allowance; defaults to nil, as the survey data has no past pension input amounts | Not modelled | + +## Where the two models differ + +### 1. Reducing taxable income versus widening the bands + +For an income-tax payer whose contribution stays inside one band, the two +methods give the same total relief. They can differ when the person pays +little or no income tax: STBM still records the basic-rate addition to the +pot, which PolicyEngine UK does not show. + +**The personal allowance taper.** In law, gross pension contributions +reduce adjusted net income +([Income Tax Act 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58)), +so contributing can restore personal allowance lost above £100,000. +PolicyEngine UK does this: `adjusted_net_income` deducts +`pension_contributions_relief`, so an employee on £110,000 has a personal +allowance of £7,570 in 2025-26, and the full £12,570 if they pay £10,000 +gross into a personal pension. STBM widens the bands but leaves the taper +income unchanged, and its code flags this as unchecked. + +### 2. Employer contributions + +STBM counts employer contributions in the relief calculation. In UK law +employer contributions are not taxed as the employee's income, so they get +no personal relief. They are simply outside taxable pay, which is how +PolicyEngine UK treats them. Including them in STBM's relief widens the +employee's bands further than the law does. + +### 3. Annual allowance taper + +The legal taper +([FA 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA)) +uses "adjusted income", which adds employer contributions, and applies only +when "threshold income" is also above £200,000 (from 2023-24). + +- PolicyEngine UK applies both tests + (`pension_annual_allowance_adjusted_income` and + `pension_annual_allowance_threshold_income`). An employee on £300,000 + who pays £100,000 gross into a personal pension in 2025-26 has adjusted + income of £300,000 but threshold income of £200,000, so keeps the full + £60,000 allowance. Its limits: by default the pension input amount is + cash contributions, so defined-benefit accrual is counted only if supplied + as an input, and all salary sacrifice is treated as made on or after + 9 July 2015. +- STBM tapers on total income above a threshold parameter. + +Both affect only very high earners. + +PolicyEngine UK does not model the money purchase annual allowance, the +£10,000 limit on money purchase savings after pensions are first flexibly +accessed +([FA 2004 s. 227ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/227ZA)). +It always uses the default chargeable amount. + +### 4. Lifetime and lump sum allowances + +The lifetime allowance was abolished from 6 April 2024 and replaced by the +lump sum allowance and the lump sum and death benefit allowance. +PolicyEngine UK has no parameters for any of these. They apply when pensions +are drawn, not to contributions. + +## What neither model does + +- Working out carry-forward of unused annual allowance from the previous + three years. PolicyEngine UK accepts the result as an input but does not + derive it from past years. +- Behavioural responses: contributions are held fixed when tax rates change. +- Decumulation: crystallisation, the tax-free lump sum, and annuity versus + drawdown choices. + +## References + +- STBM source: [`IncomeTaxCalculations.jl`][stbm-it] and [`Pensions.jl`][stbm-pen] at `f534ca1`. +- [STBM blog: pension contributions][stbm-blog]. +- PolicyEngine UK: [pension tax relief](./pension-tax-relief.md); variables [`pension_contributions_relief`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py), [`pension_annual_allowance`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py) and [`personal_pension_contributions_tax`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py). +- [Finance Act 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA) (taper) and [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) (carry-forward). +- HMRC, [Pensions Tax Manual: annual allowance taper](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm057100). +- Issue: [#672](https://github.com/PolicyEngine/policyengine-uk/issues/672). + +[stbm]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl +[stbm-it]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl/blob/f534ca1277260a6b4f24c9ab5ebfe9c015e1484d/src/IncomeTaxCalculations.jl +[stbm-pen]: https://github.com/grahamstark/ScottishTaxBenefitModel.jl/blob/f534ca1277260a6b4f24c9ab5ebfe9c015e1484d/src/Pensions.jl +[stbm-blog]: https://stb-blog.virtual-worlds.scot/articles/2022/01/01/pension-contributions.html diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index b7975450bf..ba630963eb 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -21,81 +21,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Rates\n", - "\n", - "The rates of SDLT depend on various factors, including the purchase price of the property, property type, and whether it is an additional property or a purchase by a first-time buyer. Generally, SDLT rates are higher for properties or land with a purchase price exceeding a specific threshold, often referred to as \"stamp duty bands\" or \"stamp duty brackets.\" The rules and thresholds for SDLT may change over time due to government policy adjustments. First-time buyers may be eligible for reduced SDLT rates if the purchase price of the property is not more than £500,000. \n", - "\n", - "Here we show current rates and thresholds for SDLT under different situations in 2023 below:\n", - "\n", - "
  • Residential
  • \n", - "\n", - "- Rent\n", - "\n", - " Up to £125,000: 0%\n", - " \n", - " Over £125,000: 1%\n", - "\n", - "- Purchase\n", - " - Main property \n", - " \n", - " - First-time\n", - "\n", - " Up to £300,000: 0%\n", - "\n", - " £300,000 to £500,000: 5%\n", - "\n", - " Over £500,000: Ineligible for relief, follow standard rates\n", - "\n", - " - Subsequent (standard rates):\n", - "\n", - " Up to £125,000: 0%\n", - "\n", - " £125,000 to £250,000: 2%\n", - "\n", - " £250,000 to £925,000: 5%\n", - "\n", - " £925,000 to £1,500,000: 10%\n", - "\n", - " Over £1,500,000: 12%\n", - "\n", - " - Additional property \n", - "\n", - " An additional 3% on top of the standard rates for each band. However, a secondary property or land with a purchase price or market value less than £40,000 will be exempt from SDLT.\n", - "\n", - " Less than £40,000: 0%\n", - " \n", - " £40,000 to £125,000: 3%\n", - "\n", - " £125,000 to £250,000: 5%\n", - "\n", - " £250,000 to £925,000: 8%\n", - "\n", - " £925,000 to £1,500,000: 13%\n", - "\n", - " Over £1,500,000: 15%\n", - "\n", - "
  • Non-residential
  • \n", - "\n", - "- Rent\n", - "\n", - " Up to £150,000: 0%\n", - "\n", - " £150,000 to £5,000,000: 1%\n", - " \n", - " Over £5,000,000: 2%\n", - "\n", - "- Purchase\n", - "\n", - " Up to £150,000: 0%\n", - "\n", - " £150,000 to £250,000: 2%\n", - " \n", - " Over £250,000: 5%" + "## Residential purchase schedules\n\nThis model covers recent ordinary individual purchases in England and Northern Ireland. An additional-purchase input represents a transaction already classified as subject to the higher rates; the model does not derive that classification from ownership history.\n\n| Effective date | Standard nil band | First-time-buyer nil band | Inclusive first-time-buyer maximum | Additional-dwelling surcharge |\n| --- | ---: | ---: | ---: | ---: |\n| 23 September 2022–30 October 2024 | £250,000 | £425,000 | £625,000 | 3 percentage points |\n| 31 October 2024–31 March 2025 | £250,000 | £425,000 | £625,000 | 5 percentage points |\n| From 1 April 2025 | £125,000 | £300,000 | £500,000 | 5 percentage points |\n\nAbove the temporary £250,000 standard nil band, marginal rates are 5% up to £925,000, 10% up to £1.5 million and 12% thereafter. From 1 April 2025, the £125,000–£250,000 band returns at 2%; higher standard bands remain the same. First-time buyers pay 5% above their nil band **up to and including** the maximum purchase price. A purchase above that maximum loses first-time-buyer relief on the whole transaction.\n\nFor additional dwellings, the full higher-rates schedule applies when consideration is at least £40,000; below that amount the modeled additional purchase pays zero. During the temporary period its rates are 3/8/13/15%, then 5/10/15/17% from 31 October 2024. From 1 April 2025 the restored £125,000 band gives rates of 5/7/10/15/17%. There is no 7% band during the temporary £250,000 period.\n\nSources: [SDLT (Temporary Relief) Act 2023, section 1](https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted), [Finance Act 2025, sections 50–52](https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted), and [Finance Act 2003, Schedule 6ZA](https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA).\n\n### Annual simulation convention and limits\n\nParameters retain those legal dates, but annual simulations sample **30 April** of each year. Thus 2022 uses the pre-September bands, 2023 and 2024 use the temporary bands with the three-point surcharge, and 2025 uses restored bands with the five-point surcharge. A YEAR purchase input cannot select a transaction day within that year.\n\nThe higher-rate changes protect some contracts entered into before 31 October 2024, with exclusions for specified variations, assignments, options and subsales. Substantial-performance rules can also affect liability. Existing inputs cannot select those contract protections: the schedules above describe ordinary **unprotected** purchases. Earlier 2020/2021 holiday coverage and older history remain incomplete. This purchase update does not certify rental, lease, corporate, nonresident, ownership-classification or return-rounding rules, or estimate population impacts.\n\nThe purchase curves below illustrate the **2023** marginal purchase schedules. The retained rent and non-residential curves are unchanged model illustrations outside this update's legal validation. The first-home rate curve does not show the discrete loss of relief above the maximum; the cash example below does.\n" ] }, { "cell_type": "code", - "execution_count": 77, + "execution_count": 1, "metadata": { "tags": [ "hide-input" @@ -103,11 +34,17 @@ }, "outputs": [ { + "output_type": "display_data", + "metadata": {}, + "data": { + "text/html": " \n " + } + }, + { + "output_type": "display_data", + "metadata": {}, "data": { "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, "data": [ { "hovertemplate": "Label=Residential rent
    Threshold=%{x}
    Rate=%{y}", @@ -124,7 +61,6 @@ "name": "Residential rent", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, 125000, @@ -132,15 +68,16 @@ ], "xaxis": "x", "y": [ - 0, + 0.0, 0.01, 0.01 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" }, { - "hovertemplate": "Label=Residential purchase main (first home)
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase main (first home)", + "hovertemplate": "Label=2023 residential purchase main (first home)
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase main (first home)", "line": { "color": "#EF553B", "dash": "solid", @@ -150,32 +87,32 @@ "symbol": "circle" }, "mode": "lines", - "name": "Residential purchase main (first home)", + "name": "2023 residential purchase main (first home)", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, - 300000, - 500000, + 425000, + 625000, 925000, 1500000, 5200000 ], "xaxis": "x", "y": [ - 0, + 0.0, 0.05, 0.05, 0.1, 0.12, 0.12 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" }, { - "hovertemplate": "Label=Residential purchase main standard
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase main standard", + "hovertemplate": "Label=2023 residential purchase main standard
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase main standard", "line": { "color": "#00cc96", "dash": "solid", @@ -185,13 +122,11 @@ "symbol": "circle" }, "mode": "lines", - "name": "Residential purchase main standard", + "name": "2023 residential purchase main standard", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, - 125000, 250000, 925000, 1500000, @@ -199,18 +134,18 @@ ], "xaxis": "x", "y": [ - 0, - 0.02, + 0.0, 0.05, 0.1, 0.12, 0.12 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" }, { - "hovertemplate": "Label=Residential purchase additional
    Threshold=%{x}
    Rate=%{y}", - "legendgroup": "Residential purchase additional", + "hovertemplate": "Label=2023 residential purchase additional
    Threshold=%{x}
    Rate=%{y}", + "legendgroup": "2023 residential purchase additional", "line": { "color": "#ab63fa", "dash": "solid", @@ -220,14 +155,12 @@ "symbol": "circle" }, "mode": "lines", - "name": "Residential purchase additional", + "name": "2023 residential purchase additional", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, 40000, - 125000, 250000, 925000, 1500000, @@ -235,15 +168,15 @@ ], "xaxis": "x", "y": [ - 0, + 0.0, 0.03, - 0.05, 0.08, 0.13, 0.15, 0.15 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" }, { "hovertemplate": "Label=Non-residential rent
    Threshold=%{x}
    Rate=%{y}", @@ -260,7 +193,6 @@ "name": "Non-residential rent", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, 150000, @@ -269,12 +201,13 @@ ], "xaxis": "x", "y": [ - 0, + 0.0, 0.01, 0.02, 0.02 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" }, { "hovertemplate": "Label=Non-residential purchase
    Threshold=%{x}
    Rate=%{y}", @@ -291,7 +224,6 @@ "name": "Non-residential purchase", "orientation": "v", "showlegend": true, - "type": "scatter", "x": [ 0, 150000, @@ -300,58 +232,20 @@ ], "xaxis": "x", "y": [ - 0, + 0.0, 0.02, 0.05, 0.05 ], - "yaxis": "y" + "yaxis": "y", + "type": "scatter" } ], "layout": { - "font": { - "color": "black", - "family": "Roboto Serif" - }, - "height": 600, - "images": [ - { - "sizex": 0.2, - "sizey": 0.2, - "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", - "x": 1, - "xanchor": "right", - "xref": "paper", - "y": -0.15, - "yanchor": "bottom", - "yref": "paper" - } - ], - "legend": { - "orientation": "h", - "title": { - "text": "" - }, - "tracegroupgap": 0, - "x": 0.9, - "xanchor": "right", - "y": -0.3, - "yanchor": "bottom" - }, - "modebar": { - "bgcolor": "rgba(0,0,0,0)", - "color": "rgba(0,0,0,0)" - }, "template": { "data": { - "bar": [ + "barpolar": [ { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, "marker": { "line": { "color": "white", @@ -363,11 +257,17 @@ "solidity": 0.2 } }, - "type": "bar" + "type": "barpolar" } ], - "barpolar": [ + "bar": [ { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, "marker": { "line": { "color": "white", @@ -379,7 +279,7 @@ "solidity": 0.2 } }, - "type": "barpolar" + "type": "bar" } ], "carpet": [ @@ -410,6 +310,15 @@ "type": "choropleth" } ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], "contour": [ { "colorbar": { @@ -418,7 +327,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -454,23 +363,14 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], "type": "contour" } ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ + "heatmapgl": [ { "colorbar": { "outlinewidth": 0, @@ -478,7 +378,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -514,14 +414,14 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], - "type": "heatmap" + "type": "heatmapgl" } ], - "heatmapgl": [ + "heatmap": [ { "colorbar": { "outlinewidth": 0, @@ -529,7 +429,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -565,26 +465,14 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], - "type": "heatmapgl" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" + "type": "heatmap" } ], - "histogram2d": [ + "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, @@ -592,7 +480,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -628,14 +516,14 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], - "type": "histogram2d" + "type": "histogram2dcontour" } ], - "histogram2dcontour": [ + "histogram2d": [ { "colorbar": { "outlinewidth": 0, @@ -643,7 +531,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -679,11 +567,23 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], - "type": "histogram2dcontour" + "type": "histogram2d" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" } ], "mesh3d": [ @@ -712,16 +612,6 @@ "type": "pie" } ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], "scatter3d": [ { "line": { @@ -783,7 +673,7 @@ "type": "scattermapbox" } ], - "scatterpolar": [ + "scatterpolargl": [ { "marker": { "colorbar": { @@ -791,10 +681,10 @@ "ticks": "" } }, - "type": "scatterpolar" + "type": "scatterpolargl" } ], - "scatterpolargl": [ + "scatterpolar": [ { "marker": { "colorbar": { @@ -802,7 +692,17 @@ "ticks": "" } }, - "type": "scatterpolargl" + "type": "scatterpolar" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" } ], "scatterternary": [ @@ -824,7 +724,7 @@ }, "colorscale": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -860,7 +760,7 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], @@ -951,7 +851,7 @@ ], "sequential": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -987,13 +887,13 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ], "sequentialminus": [ [ - 0, + 0.0, "#0d0887" ], [ @@ -1029,7 +929,7 @@ "#fdca26" ], [ - 1, + 1.0, "#f0f921" ] ] @@ -1158,37 +1058,72 @@ } } }, - "title": { - "text": "Stamp duty land tax rates over property price thresholds" - }, - "width": 800, "xaxis": { "anchor": "y", "domain": [ - 0, - 1 + 0.0, + 1.0 ], - "tickprefix": "£", "title": { "text": "Threshold" - } + }, + "tickprefix": "£" }, "yaxis": { "anchor": "x", "domain": [ - 0, - 1 + 0.0, + 1.0 ], - "tickformat": ",.0%", "title": { "text": "Rate" + }, + "tickformat": ",.0%" + }, + "legend": { + "title": { + "text": "" + }, + "tracegroupgap": 0, + "orientation": "h", + "yanchor": "bottom", + "y": -0.3, + "xanchor": "right", + "x": 0.9 + }, + "title": { + "text": "Stamp duty land tax rates over property price thresholds" + }, + "font": { + "family": "Roboto Serif", + "color": "black" + }, + "images": [ + { + "sizex": 0.15, + "sizey": 0.15, + "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", + "x": 1.1, + "xanchor": "right", + "xref": "paper", + "y": -0.15, + "yanchor": "bottom", + "yref": "paper" } + ], + "height": 600, + "width": 800, + "modebar": { + "bgcolor": "rgba(0,0,0,0)", + "color": "rgba(0,0,0,0)" } + }, + "config": { + "plotlyServerURL": "https://plot.ly" } - } - }, - "metadata": {}, - "output_type": "display_data" + }, + "text/html": "
    " + } } ], "source": [ @@ -1205,23 +1140,23 @@ ")\n", "residential_purchase_main_first = pd.DataFrame(\n", " {\n", - " \"Threshold\": [0, 300000, 500000, 925000, 1500000, 5200000],\n", + " \"Threshold\": [0, 425000, 625000, 925000, 1500000, 5200000],\n", " \"Rate\": [0, 0.05, 0.05, 0.1, 0.12, 0.12],\n", - " \"Label\": [\"Residential purchase main (first home)\"] * 6,\n", + " \"Label\": [\"2023 residential purchase main (first home)\"] * 6,\n", " }\n", ")\n", "residential_purchase_main_standard = pd.DataFrame(\n", " {\n", - " \"Threshold\": [0, 125000, 250000, 925000, 1500000, 5200000],\n", - " \"Rate\": [0, 0.02, 0.05, 0.1, 0.12, 0.12],\n", - " \"Label\": [\"Residential purchase main standard\"] * 6,\n", + " \"Threshold\": [0, 250000, 925000, 1500000, 5200000],\n", + " \"Rate\": [0, 0.05, 0.1, 0.12, 0.12],\n", + " \"Label\": [\"2023 residential purchase main standard\"] * 5,\n", " }\n", ")\n", "residential_purchase_additional = pd.DataFrame(\n", " {\n", - " \"Threshold\": [0, 40000, 125000, 250000, 925000, 1500000, 5200000],\n", - " \"Rate\": [0, 0.03, 0.05, 0.08, 0.13, 0.15, 0.15],\n", - " \"Label\": [\"Residential purchase additional\"] * 7,\n", + " \"Threshold\": [0, 40000, 250000, 925000, 1500000, 5200000],\n", + " \"Rate\": [0, 0.03, 0.08, 0.13, 0.15, 0.15],\n", + " \"Label\": [\"2023 residential purchase additional\"] * 6,\n", " }\n", ")\n", "non_residential_rent = pd.DataFrame(\n", @@ -1278,12 +1213,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The first home discount only applies for homes priced under £500,000. When simulating the marginal effect of SDLT, we find a cliff effect for first-time buyers." + "### Inclusive cap and loss of relief\n\nFor an otherwise qualifying first-time buyer in the 2023 annual simulation, a £625,000 purchase pays £10,000: (£625,000 − £425,000) × 5%. At £625,020 the purchase exceeds the inclusive maximum and instead pays ordinary SDLT of £18,751: (£625,020 − £250,000) × 5%. These are simple unprotected purchases with all other relief conditions satisfied.\n" ] }, { "cell_type": "code", - "execution_count": 88, + "execution_count": 2, "metadata": { "tags": [ "hide-input" @@ -1291,5042 +1226,37 @@ }, "outputs": [ { + "output_type": "execute_result", + "metadata": {}, "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "hovertemplate": "Home price=%{x}
    Marginal SDLT rate=%{y}", - "legendgroup": "", - "line": { - "color": "#636efa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "", - "orientation": "v", - "showlegend": false, - "type": "scatter", - "x": [ - 0, - 5005.0048828125, - 10010.009765625, - 15015.0146484375, - 20020.01953125, - 25025.025390625, - 30030.029296875, - 35035.03515625, - 40040.0390625, - 45045.046875, - 50050.05078125, - 55055.0546875, - 60060.05859375, - 65065.06640625, - 70070.0703125, - 75075.078125, - 80080.078125, - 85085.0859375, - 90090.09375, - 95095.09375, - 100100.1015625, - 105105.1015625, - 110110.109375, - 115115.1171875, - 120120.1171875, - 125125.125, - 130130.1328125, - 135135.140625, - 140140.140625, - 145145.140625, - 150150.15625, - 155155.15625, - 160160.15625, - 165165.171875, - 170170.171875, - 175175.171875, - 180180.1875, - 185185.1875, - 190190.1875, - 195195.1875, - 200200.203125, - 205205.203125, - 210210.203125, - 215215.21875, - 220220.21875, - 225225.21875, - 230230.234375, - 235235.234375, - 240240.234375, - 245245.25, - 250250.25, - 255255.25, - 260260.265625, - 265265.25, - 270270.28125, - 275275.28125, - 280280.28125, - 285285.28125, - 290290.28125, - 295295.28125, - 300300.3125, - 305305.3125, - 310310.3125, - 315315.3125, - 320320.3125, - 325325.3125, - 330330.34375, - 335335.34375, - 340340.34375, - 345345.34375, - 350350.34375, - 355355.34375, - 360360.375, - 365365.375, - 370370.375, - 375375.375, - 380380.375, - 385385.375, - 390390.375, - 395395.40625, - 400400.40625, - 405405.40625, - 410410.40625, - 415415.40625, - 420420.40625, - 425425.4375, - 430430.4375, - 435435.4375, - 440440.4375, - 445445.4375, - 450450.4375, - 455455.46875, - 460460.46875, - 465465.46875, - 470470.46875, - 475475.46875, - 480480.46875, - 485485.5, - 490490.5, - 495495.5, - 500500.5, - 505505.5, - 510510.5, - 515515.5, - 520520.53125, - 525525.5, - 530530.5, - 535535.5625, - 540540.5625, - 545545.5625, - 550550.5625, - 555555.5625, - 560560.5625, - 565565.5625, - 570570.5625, - 575575.5625, - 580580.5625, - 585585.5625, - 590590.5625, - 595595.625, - 600600.625, - 605605.625, - 610610.625, - 615615.625, - 620620.625, - 625625.625, - 630630.625, - 635635.625, - 640640.625, - 645645.625, - 650650.625, - 655655.625, - 660660.6875, - 665665.6875, - 670670.6875, - 675675.6875, - 680680.6875, - 685685.6875, - 690690.6875, - 695695.6875, - 700700.6875, - 705705.6875, - 710710.6875, - 715715.6875, - 720720.75, - 725725.75, - 730730.75, - 735735.75, - 740740.75, - 745745.75, - 750750.75, - 755755.75, - 760760.75, - 765765.75, - 770770.75, - 775775.75, - 780780.75, - 785785.8125, - 790790.8125, - 795795.8125, - 800800.8125, - 805805.8125, - 810810.8125, - 815815.8125, - 820820.8125, - 825825.8125, - 830830.8125, - 835835.8125, - 840840.8125, - 845845.875, - 850850.875, - 855855.875, - 860860.875, - 865865.875, - 870870.875, - 875875.875, - 880880.875, - 885885.875, - 890890.875, - 895895.875, - 900900.875, - 905905.875, - 910910.9375, - 915915.9375, - 920920.9375, - 925925.9375, - 930930.9375, - 935935.9375, - 940940.9375, - 945945.9375, - 950950.9375, - 955955.9375, - 960960.9375, - 965965.9375, - 970971, - 975976, - 980981, - 985986, - 990991, - 995996, - 1001001, - 1006006, - 1011011, - 1016016, - 1021021, - 1026026, - 1031031, - 1036036.0625, - 1041041.0625, - 1046046.0625, - 1051051, - 1056056, - 1061061, - 1066066.125, - 1071071.125, - 1076076.125, - 1081081.125, - 1086086.125, - 1091091.125, - 1096096.125, - 1101101.125, - 1106106.125, - 1111111.125, - 1116116.125, - 1121121.125, - 1126126.125, - 1131131.125, - 1136136.125, - 1141141.125, - 1146146.125, - 1151151.125, - 1156156.125, - 1161161.125, - 1166166.125, - 1171171.125, - 1176176.125, - 1181181.125, - 1186186.125, - 1191191.25, - 1196196.25, - 1201201.25, - 1206206.25, - 1211211.25, - 1216216.25, - 1221221.25, - 1226226.25, - 1231231.25, - 1236236.25, - 1241241.25, - 1246246.25, - 1251251.25, - 1256256.25, - 1261261.25, - 1266266.25, - 1271271.25, - 1276276.25, - 1281281.25, - 1286286.25, - 1291291.25, - 1296296.25, - 1301301.25, - 1306306.25, - 1311311.25, - 1316316.375, - 1321321.375, - 1326326.375, - 1331331.375, - 1336336.375, - 1341341.375, - 1346346.375, - 1351351.375, - 1356356.375, - 1361361.375, - 1366366.375, - 1371371.375, - 1376376.375, - 1381381.375, - 1386386.375, - 1391391.375, - 1396396.375, - 1401401.375, - 1406406.375, - 1411411.375, - 1416416.375, - 1421421.375, - 1426426.375, - 1431431.375, - 1436436.375, - 1441441.5, - 1446446.5, - 1451451.5, - 1456456.5, - 1461461.5, - 1466466.5, - 1471471.5, - 1476476.5, - 1481481.5, - 1486486.5, - 1491491.5, - 1496496.5, - 1501501.5, - 1506506.5, - 1511511.5, - 1516516.5, - 1521521.5, - 1526526.5, - 1531531.5, - 1536536.5, - 1541541.5, - 1546546.5, - 1551551.5, - 1556556.5, - 1561561.5, - 1566566.625, - 1571571.625, - 1576576.625, - 1581581.625, - 1586586.625, - 1591591.625, - 1596596.625, - 1601601.625, - 1606606.625, - 1611611.625, - 1616616.625, - 1621621.625, - 1626626.625, - 1631631.625, - 1636636.625, - 1641641.625, - 1646646.625, - 1651651.625, - 1656656.625, - 1661661.625, - 1666666.625, - 1671671.625, - 1676676.625, - 1681681.625, - 1686686.625, - 1691691.75, - 1696696.75, - 1701701.75, - 1706706.75, - 1711711.75, - 1716716.75, - 1721721.75, - 1726726.75, - 1731731.75, - 1736736.75, - 1741741.75, - 1746746.75, - 1751751.75, - 1756756.75, - 1761761.75, - 1766766.75, - 1771771.75, - 1776776.75, - 1781781.75, - 1786786.75, - 1791791.75, - 1796796.75, - 1801801.75, - 1806806.75, - 1811811.75, - 1816816.875, - 1821821.875, - 1826826.875, - 1831831.875, - 1836836.875, - 1841841.875, - 1846846.875, - 1851851.875, - 1856856.875, - 1861861.875, - 1866866.875, - 1871871.875, - 1876876.875, - 1881881.875, - 1886886.875, - 1891891.875, - 1896896.875, - 1901901.875, - 1906906.875, - 1911911.875, - 1916916.875, - 1921921.875, - 1926926.875, - 1931931.875, - 1936936.875, - 1941942, - 1946947, - 1951952, - 1956957, - 1961962, - 1966967, - 1971972, - 1976977, - 1981982, - 1986987, - 1991992, - 1996997, - 2002002, - 2007007, - 2012012, - 2017017, - 2022022, - 2027027, - 2032032, - 2037037, - 2042042, - 2047047, - 2052052, - 2057057, - 2062062, - 2067067.125, - 2072072.125, - 2077077.125, - 2082082.125, - 2087087.125, - 2092092.125, - 2097097.125, - 2102102, - 2107107, - 2112112, - 2117117, - 2122122, - 2127127.25, - 2132132.25, - 2137137.25, - 2142142.25, - 2147147.25, - 2152152.25, - 2157157.25, - 2162162.25, - 2167167.25, - 2172172.25, - 2177177.25, - 2182182.25, - 2187187.25, - 2192192.25, - 2197197.25, - 2202202.25, - 2207207.25, - 2212212.25, - 2217217.25, - 2222222.25, - 2227227.25, - 2232232.25, - 2237237.25, - 2242242.25, - 2247247.25, - 2252252.25, - 2257257.25, - 2262262.25, - 2267267.25, - 2272272.25, - 2277277.25, - 2282282.25, - 2287287.25, - 2292292.25, - 2297297.25, - 2302302.25, - 2307307.25, - 2312312.25, - 2317317.25, - 2322322.25, - 2327327.25, - 2332332.25, - 2337337.25, - 2342342.25, - 2347347.25, - 2352352.25, - 2357357.25, - 2362362.25, - 2367367.25, - 2372372.25, - 2377377.5, - 2382382.5, - 2387387.5, - 2392392.5, - 2397397.5, - 2402402.5, - 2407407.5, - 2412412.5, - 2417417.5, - 2422422.5, - 2427427.5, - 2432432.5, - 2437437.5, - 2442442.5, - 2447447.5, - 2452452.5, - 2457457.5, - 2462462.5, - 2467467.5, - 2472472.5, - 2477477.5, - 2482482.5, - 2487487.5, - 2492492.5, - 2497497.5, - 2502502.5, - 2507507.5, - 2512512.5, - 2517517.5, - 2522522.5, - 2527527.5, - 2532532.5, - 2537537.5, - 2542542.5, - 2547547.5, - 2552552.5, - 2557557.5, - 2562562.5, - 2567567.5, - 2572572.5, - 2577577.5, - 2582582.5, - 2587587.5, - 2592592.5, - 2597597.5, - 2602602.5, - 2607607.5, - 2612612.5, - 2617617.5, - 2622622.5, - 2627627.75, - 2632632.75, - 2637637.75, - 2642642.75, - 2647647.75, - 2652652.75, - 2657657.75, - 2662662.75, - 2667667.75, - 2672672.75, - 2677677.75, - 2682682.75, - 2687687.75, - 2692692.75, - 2697697.75, - 2702702.75, - 2707707.75, - 2712712.75, - 2717717.75, - 2722722.75, - 2727727.75, - 2732732.75, - 2737737.75, - 2742742.75, - 2747747.75, - 2752752.75, - 2757757.75, - 2762762.75, - 2767767.75, - 2772772.75, - 2777777.75, - 2782782.75, - 2787787.75, - 2792792.75, - 2797797.75, - 2802802.75, - 2807807.75, - 2812812.75, - 2817817.75, - 2822822.75, - 2827827.75, - 2832832.75, - 2837837.75, - 2842842.75, - 2847847.75, - 2852852.75, - 2857857.75, - 2862862.75, - 2867867.75, - 2872872.75, - 2877878, - 2882883, - 2887888, - 2892893, - 2897898, - 2902903, - 2907908, - 2912913, - 2917918, - 2922923, - 2927928, - 2932933, - 2937938, - 2942943, - 2947948, - 2952953, - 2957958, - 2962963, - 2967968, - 2972973, - 2977978, - 2982983, - 2987988, - 2992993, - 2997998, - 3003003, - 3008008, - 3013013, - 3018018, - 3023023, - 3028028, - 3033033, - 3038038, - 3043043, - 3048048, - 3053053, - 3058058, - 3063063, - 3068068, - 3073073, - 3078078, - 3083083, - 3088088, - 3093093, - 3098098, - 3103103, - 3108108, - 3113113, - 3118118, - 3123123, - 3128128.25, - 3133133.25, - 3138138.25, - 3143143.25, - 3148148.25, - 3153153.25, - 3158158.25, - 3163163.25, - 3168168.25, - 3173173.25, - 3178178.25, - 3183183.25, - 3188188.25, - 3193193.25, - 3198198.25, - 3203203.25, - 3208208.25, - 3213213.25, - 3218218.25, - 3223223.25, - 3228228.25, - 3233233.25, - 3238238.25, - 3243243.25, - 3248248.25, - 3253253.25, - 3258258.25, - 3263263.25, - 3268268.25, - 3273273.25, - 3278278.25, - 3283283.25, - 3288288.25, - 3293293.25, - 3298298.25, - 3303303.25, - 3308308.25, - 3313313.25, - 3318318.25, - 3323323.25, - 3328328.25, - 3333333.25, - 3338338.25, - 3343343.25, - 3348348.25, - 3353353.25, - 3358358.25, - 3363363.25, - 3368368.25, - 3373373.25, - 3378378.5, - 3383383.5, - 3388388.5, - 3393393.5, - 3398398.5, - 3403403.5, - 3408408.5, - 3413413.5, - 3418418.5, - 3423423.5, - 3428428.5, - 3433433.5, - 3438438.5, - 3443443.5, - 3448448.5, - 3453453.5, - 3458458.5, - 3463463.5, - 3468468.5, - 3473473.5, - 3478478.5, - 3483483.5, - 3488488.5, - 3493493.5, - 3498498.5, - 3503503.5, - 3508508.5, - 3513513.5, - 3518518.5, - 3523523.5, - 3528528.5, - 3533533.5, - 3538538.5, - 3543543.5, - 3548548.5, - 3553553.5, - 3558558.5, - 3563563.5, - 3568568.5, - 3573573.5, - 3578578.5, - 3583583.5, - 3588588.5, - 3593593.5, - 3598598.5, - 3603603.5, - 3608608.5, - 3613613.5, - 3618618.5, - 3623623.5, - 3628628.75, - 3633633.75, - 3638638.75, - 3643643.75, - 3648648.75, - 3653653.75, - 3658658.75, - 3663663.75, - 3668668.75, - 3673673.75, - 3678678.75, - 3683683.75, - 3688688.75, - 3693693.75, - 3698698.75, - 3703703.75, - 3708708.75, - 3713713.75, - 3718718.75, - 3723723.75, - 3728728.75, - 3733733.75, - 3738738.75, - 3743743.75, - 3748748.75, - 3753753.75, - 3758758.75, - 3763763.75, - 3768768.75, - 3773773.75, - 3778778.75, - 3783783.75, - 3788788.75, - 3793793.75, - 3798798.75, - 3803803.75, - 3808808.75, - 3813813.75, - 3818818.75, - 3823823.75, - 3828828.75, - 3833833.75, - 3838838.75, - 3843843.75, - 3848848.75, - 3853853.75, - 3858858.75, - 3863863.75, - 3868868.75, - 3873873.75, - 3878879, - 3883884, - 3888889, - 3893894, - 3898899, - 3903904, - 3908909, - 3913914, - 3918919, - 3923924, - 3928929, - 3933934, - 3938939, - 3943944, - 3948949, - 3953954, - 3958959, - 3963964, - 3968969, - 3973974, - 3978979, - 3983984, - 3988989, - 3993994, - 3998999, - 4004004, - 4009009, - 4014014, - 4019019, - 4024024, - 4029029, - 4034034, - 4039039, - 4044044, - 4049049, - 4054054, - 4059059, - 4064064, - 4069069, - 4074074, - 4079079, - 4084084, - 4089089, - 4094094, - 4099099, - 4104104, - 4109109, - 4114114, - 4119119, - 4124124, - 4129129.25, - 4134134.25, - 4139139.25, - 4144144.25, - 4149149.25, - 4154154.25, - 4159159.25, - 4164164.25, - 4169169.25, - 4174174.25, - 4179179.25, - 4184184.25, - 4189189.25, - 4194194.25, - 4199199, - 4204204, - 4209209, - 4214214, - 4219219, - 4224224, - 4229229, - 4234234, - 4239239, - 4244244, - 4249249, - 4254254.5, - 4259259.5, - 4264264.5, - 4269269.5, - 4274274.5, - 4279279.5, - 4284284.5, - 4289289.5, - 4294294.5, - 4299299.5, - 4304304.5, - 4309309.5, - 4314314.5, - 4319319.5, - 4324324.5, - 4329329.5, - 4334334.5, - 4339339.5, - 4344344.5, - 4349349.5, - 4354354.5, - 4359359.5, - 4364364.5, - 4369369.5, - 4374374.5, - 4379379.5, - 4384384.5, - 4389389.5, - 4394394.5, - 4399399.5, - 4404404.5, - 4409409.5, - 4414414.5, - 4419419.5, - 4424424.5, - 4429429.5, - 4434434.5, - 4439439.5, - 4444444.5, - 4449449.5, - 4454454.5, - 4459459.5, - 4464464.5, - 4469469.5, - 4474474.5, - 4479479.5, - 4484484.5, - 4489489.5, - 4494494.5, - 4499499.5, - 4504504.5, - 4509509.5, - 4514514.5, - 4519519.5, - 4524524.5, - 4529529.5, - 4534534.5, - 4539539.5, - 4544544.5, - 4549549.5, - 4554554.5, - 4559559.5, - 4564564.5, - 4569569.5, - 4574574.5, - 4579579.5, - 4584584.5, - 4589589.5, - 4594594.5, - 4599599.5, - 4604604.5, - 4609609.5, - 4614614.5, - 4619619.5, - 4624624.5, - 4629629.5, - 4634634.5, - 4639639.5, - 4644644.5, - 4649649.5, - 4654654.5, - 4659659.5, - 4664664.5, - 4669669.5, - 4674674.5, - 4679679.5, - 4684684.5, - 4689689.5, - 4694694.5, - 4699699.5, - 4704704.5, - 4709709.5, - 4714714.5, - 4719719.5, - 4724724.5, - 4729729.5, - 4734734.5, - 4739739.5, - 4744744.5, - 4749749.5, - 4754755, - 4759760, - 4764765, - 4769770, - 4774775, - 4779780, - 4784785, - 4789790, - 4794795, - 4799800, - 4804805, - 4809810, - 4814815, - 4819820, - 4824825, - 4829830, - 4834835, - 4839840, - 4844845, - 4849850, - 4854855, - 4859860, - 4864865, - 4869870, - 4874875, - 4879880, - 4884885, - 4889890, - 4894895, - 4899900, - 4904905, - 4909910, - 4914915, - 4919920, - 4924925, - 4929930, - 4934935, - 4939940, - 4944945, - 4949950, - 4954955, - 4959960, - 4964965, - 4969970, - 4974975, - 4979980, - 4984985, - 4989990, - 4994995 - ], - "xaxis": "x", - "y": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0.0030001061968505383, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000447034836, - 0.05000000074505806, - 0.05000000074505806, - 0.04999997466802597, - 0.05000000074505806, - 0.05000000074505806, - 0.05000003054738045, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999995231628418, - 0.05000000074505806, - 0.05000007897615433, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999998211860657, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999998211860657, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000007897615433, - 0.05000000074505806, - 0.05000000074505806, - 1.0490009784698486, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999988153576851, - 0.05000011622905731, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.04999976605176926, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.05000000074505806, - 0.050000157207250595, - 0.05000000074505806, - 0.05000000074505806, - 0.0592501237988472, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.1000003144145012, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999953210353851, - 0.10000000149011612, - 0.10000000149011612, - 0.10000047087669373, - 0.10000000149011612, - 0.10000000149011612, - 0.09999984502792358, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999984502792358, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000062733888626, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.09999906271696092, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10000000149011612, - 0.10600025206804276, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.11999887973070145, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.11999887973070145, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.12000031024217606, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.12000200152397156, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.11999862641096117, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1200018748641014, - 0.119998998939991, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1200018748641014, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.119998998939991, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.119998499751091, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.11999925225973128, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.11999925225973128, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.1199987530708313, - 0.12000499665737152, - 0.1199987530708313, - 0.1199987530708313 - ], - "yaxis": "y" - }, - { - "hovertemplate": "Home price=%{x}
    SDLT=%{y}", - "legendgroup": "", - "line": { - "color": "#636efa", - "dash": "solid", - "shape": "hv" - }, - "marker": { - "symbol": "circle" - }, - "mode": "lines", - "name": "", - "orientation": "v", - "showlegend": false, - "type": "scatter", - "x": [ - 0, - 5005.0048828125, - 10010.009765625, - 15015.0146484375, - 20020.01953125, - 25025.025390625, - 30030.029296875, - 35035.03515625, - 40040.0390625, - 45045.046875, - 50050.05078125, - 55055.0546875, - 60060.05859375, - 65065.06640625, - 70070.0703125, - 75075.078125, - 80080.078125, - 85085.0859375, - 90090.09375, - 95095.09375, - 100100.1015625, - 105105.1015625, - 110110.109375, - 115115.1171875, - 120120.1171875, - 125125.125, - 130130.1328125, - 135135.140625, - 140140.140625, - 145145.140625, - 150150.15625, - 155155.15625, - 160160.15625, - 165165.171875, - 170170.171875, - 175175.171875, - 180180.1875, - 185185.1875, - 190190.1875, - 195195.1875, - 200200.203125, - 205205.203125, - 210210.203125, - 215215.21875, - 220220.21875, - 225225.21875, - 230230.234375, - 235235.234375, - 240240.234375, - 245245.25, - 250250.25, - 255255.25, - 260260.265625, - 265265.25, - 270270.28125, - 275275.28125, - 280280.28125, - 285285.28125, - 290290.28125, - 295295.28125, - 300300.3125, - 305305.3125, - 310310.3125, - 315315.3125, - 320320.3125, - 325325.3125, - 330330.34375, - 335335.34375, - 340340.34375, - 345345.34375, - 350350.34375, - 355355.34375, - 360360.375, - 365365.375, - 370370.375, - 375375.375, - 380380.375, - 385385.375, - 390390.375, - 395395.40625, - 400400.40625, - 405405.40625, - 410410.40625, - 415415.40625, - 420420.40625, - 425425.4375, - 430430.4375, - 435435.4375, - 440440.4375, - 445445.4375, - 450450.4375, - 455455.46875, - 460460.46875, - 465465.46875, - 470470.46875, - 475475.46875, - 480480.46875, - 485485.5, - 490490.5, - 495495.5, - 500500.5, - 505505.5, - 510510.5, - 515515.5, - 520520.53125, - 525525.5, - 530530.5, - 535535.5625, - 540540.5625, - 545545.5625, - 550550.5625, - 555555.5625, - 560560.5625, - 565565.5625, - 570570.5625, - 575575.5625, - 580580.5625, - 585585.5625, - 590590.5625, - 595595.625, - 600600.625, - 605605.625, - 610610.625, - 615615.625, - 620620.625, - 625625.625, - 630630.625, - 635635.625, - 640640.625, - 645645.625, - 650650.625, - 655655.625, - 660660.6875, - 665665.6875, - 670670.6875, - 675675.6875, - 680680.6875, - 685685.6875, - 690690.6875, - 695695.6875, - 700700.6875, - 705705.6875, - 710710.6875, - 715715.6875, - 720720.75, - 725725.75, - 730730.75, - 735735.75, - 740740.75, - 745745.75, - 750750.75, - 755755.75, - 760760.75, - 765765.75, - 770770.75, - 775775.75, - 780780.75, - 785785.8125, - 790790.8125, - 795795.8125, - 800800.8125, - 805805.8125, - 810810.8125, - 815815.8125, - 820820.8125, - 825825.8125, - 830830.8125, - 835835.8125, - 840840.8125, - 845845.875, - 850850.875, - 855855.875, - 860860.875, - 865865.875, - 870870.875, - 875875.875, - 880880.875, - 885885.875, - 890890.875, - 895895.875, - 900900.875, - 905905.875, - 910910.9375, - 915915.9375, - 920920.9375, - 925925.9375, - 930930.9375, - 935935.9375, - 940940.9375, - 945945.9375, - 950950.9375, - 955955.9375, - 960960.9375, - 965965.9375, - 970971, - 975976, - 980981, - 985986, - 990991, - 995996, - 1001001, - 1006006, - 1011011, - 1016016, - 1021021, - 1026026, - 1031031, - 1036036.0625, - 1041041.0625, - 1046046.0625, - 1051051, - 1056056, - 1061061, - 1066066.125, - 1071071.125, - 1076076.125, - 1081081.125, - 1086086.125, - 1091091.125, - 1096096.125, - 1101101.125, - 1106106.125, - 1111111.125, - 1116116.125, - 1121121.125, - 1126126.125, - 1131131.125, - 1136136.125, - 1141141.125, - 1146146.125, - 1151151.125, - 1156156.125, - 1161161.125, - 1166166.125, - 1171171.125, - 1176176.125, - 1181181.125, - 1186186.125, - 1191191.25, - 1196196.25, - 1201201.25, - 1206206.25, - 1211211.25, - 1216216.25, - 1221221.25, - 1226226.25, - 1231231.25, - 1236236.25, - 1241241.25, - 1246246.25, - 1251251.25, - 1256256.25, - 1261261.25, - 1266266.25, - 1271271.25, - 1276276.25, - 1281281.25, - 1286286.25, - 1291291.25, - 1296296.25, - 1301301.25, - 1306306.25, - 1311311.25, - 1316316.375, - 1321321.375, - 1326326.375, - 1331331.375, - 1336336.375, - 1341341.375, - 1346346.375, - 1351351.375, - 1356356.375, - 1361361.375, - 1366366.375, - 1371371.375, - 1376376.375, - 1381381.375, - 1386386.375, - 1391391.375, - 1396396.375, - 1401401.375, - 1406406.375, - 1411411.375, - 1416416.375, - 1421421.375, - 1426426.375, - 1431431.375, - 1436436.375, - 1441441.5, - 1446446.5, - 1451451.5, - 1456456.5, - 1461461.5, - 1466466.5, - 1471471.5, - 1476476.5, - 1481481.5, - 1486486.5, - 1491491.5, - 1496496.5, - 1501501.5, - 1506506.5, - 1511511.5, - 1516516.5, - 1521521.5, - 1526526.5, - 1531531.5, - 1536536.5, - 1541541.5, - 1546546.5, - 1551551.5, - 1556556.5, - 1561561.5, - 1566566.625, - 1571571.625, - 1576576.625, - 1581581.625, - 1586586.625, - 1591591.625, - 1596596.625, - 1601601.625, - 1606606.625, - 1611611.625, - 1616616.625, - 1621621.625, - 1626626.625, - 1631631.625, - 1636636.625, - 1641641.625, - 1646646.625, - 1651651.625, - 1656656.625, - 1661661.625, - 1666666.625, - 1671671.625, - 1676676.625, - 1681681.625, - 1686686.625, - 1691691.75, - 1696696.75, - 1701701.75, - 1706706.75, - 1711711.75, - 1716716.75, - 1721721.75, - 1726726.75, - 1731731.75, - 1736736.75, - 1741741.75, - 1746746.75, - 1751751.75, - 1756756.75, - 1761761.75, - 1766766.75, - 1771771.75, - 1776776.75, - 1781781.75, - 1786786.75, - 1791791.75, - 1796796.75, - 1801801.75, - 1806806.75, - 1811811.75, - 1816816.875, - 1821821.875, - 1826826.875, - 1831831.875, - 1836836.875, - 1841841.875, - 1846846.875, - 1851851.875, - 1856856.875, - 1861861.875, - 1866866.875, - 1871871.875, - 1876876.875, - 1881881.875, - 1886886.875, - 1891891.875, - 1896896.875, - 1901901.875, - 1906906.875, - 1911911.875, - 1916916.875, - 1921921.875, - 1926926.875, - 1931931.875, - 1936936.875, - 1941942, - 1946947, - 1951952, - 1956957, - 1961962, - 1966967, - 1971972, - 1976977, - 1981982, - 1986987, - 1991992, - 1996997, - 2002002, - 2007007, - 2012012, - 2017017, - 2022022, - 2027027, - 2032032, - 2037037, - 2042042, - 2047047, - 2052052, - 2057057, - 2062062, - 2067067.125, - 2072072.125, - 2077077.125, - 2082082.125, - 2087087.125, - 2092092.125, - 2097097.125, - 2102102, - 2107107, - 2112112, - 2117117, - 2122122, - 2127127.25, - 2132132.25, - 2137137.25, - 2142142.25, - 2147147.25, - 2152152.25, - 2157157.25, - 2162162.25, - 2167167.25, - 2172172.25, - 2177177.25, - 2182182.25, - 2187187.25, - 2192192.25, - 2197197.25, - 2202202.25, - 2207207.25, - 2212212.25, - 2217217.25, - 2222222.25, - 2227227.25, - 2232232.25, - 2237237.25, - 2242242.25, - 2247247.25, - 2252252.25, - 2257257.25, - 2262262.25, - 2267267.25, - 2272272.25, - 2277277.25, - 2282282.25, - 2287287.25, - 2292292.25, - 2297297.25, - 2302302.25, - 2307307.25, - 2312312.25, - 2317317.25, - 2322322.25, - 2327327.25, - 2332332.25, - 2337337.25, - 2342342.25, - 2347347.25, - 2352352.25, - 2357357.25, - 2362362.25, - 2367367.25, - 2372372.25, - 2377377.5, - 2382382.5, - 2387387.5, - 2392392.5, - 2397397.5, - 2402402.5, - 2407407.5, - 2412412.5, - 2417417.5, - 2422422.5, - 2427427.5, - 2432432.5, - 2437437.5, - 2442442.5, - 2447447.5, - 2452452.5, - 2457457.5, - 2462462.5, - 2467467.5, - 2472472.5, - 2477477.5, - 2482482.5, - 2487487.5, - 2492492.5, - 2497497.5, - 2502502.5, - 2507507.5, - 2512512.5, - 2517517.5, - 2522522.5, - 2527527.5, - 2532532.5, - 2537537.5, - 2542542.5, - 2547547.5, - 2552552.5, - 2557557.5, - 2562562.5, - 2567567.5, - 2572572.5, - 2577577.5, - 2582582.5, - 2587587.5, - 2592592.5, - 2597597.5, - 2602602.5, - 2607607.5, - 2612612.5, - 2617617.5, - 2622622.5, - 2627627.75, - 2632632.75, - 2637637.75, - 2642642.75, - 2647647.75, - 2652652.75, - 2657657.75, - 2662662.75, - 2667667.75, - 2672672.75, - 2677677.75, - 2682682.75, - 2687687.75, - 2692692.75, - 2697697.75, - 2702702.75, - 2707707.75, - 2712712.75, - 2717717.75, - 2722722.75, - 2727727.75, - 2732732.75, - 2737737.75, - 2742742.75, - 2747747.75, - 2752752.75, - 2757757.75, - 2762762.75, - 2767767.75, - 2772772.75, - 2777777.75, - 2782782.75, - 2787787.75, - 2792792.75, - 2797797.75, - 2802802.75, - 2807807.75, - 2812812.75, - 2817817.75, - 2822822.75, - 2827827.75, - 2832832.75, - 2837837.75, - 2842842.75, - 2847847.75, - 2852852.75, - 2857857.75, - 2862862.75, - 2867867.75, - 2872872.75, - 2877878, - 2882883, - 2887888, - 2892893, - 2897898, - 2902903, - 2907908, - 2912913, - 2917918, - 2922923, - 2927928, - 2932933, - 2937938, - 2942943, - 2947948, - 2952953, - 2957958, - 2962963, - 2967968, - 2972973, - 2977978, - 2982983, - 2987988, - 2992993, - 2997998, - 3003003, - 3008008, - 3013013, - 3018018, - 3023023, - 3028028, - 3033033, - 3038038, - 3043043, - 3048048, - 3053053, - 3058058, - 3063063, - 3068068, - 3073073, - 3078078, - 3083083, - 3088088, - 3093093, - 3098098, - 3103103, - 3108108, - 3113113, - 3118118, - 3123123, - 3128128.25, - 3133133.25, - 3138138.25, - 3143143.25, - 3148148.25, - 3153153.25, - 3158158.25, - 3163163.25, - 3168168.25, - 3173173.25, - 3178178.25, - 3183183.25, - 3188188.25, - 3193193.25, - 3198198.25, - 3203203.25, - 3208208.25, - 3213213.25, - 3218218.25, - 3223223.25, - 3228228.25, - 3233233.25, - 3238238.25, - 3243243.25, - 3248248.25, - 3253253.25, - 3258258.25, - 3263263.25, - 3268268.25, - 3273273.25, - 3278278.25, - 3283283.25, - 3288288.25, - 3293293.25, - 3298298.25, - 3303303.25, - 3308308.25, - 3313313.25, - 3318318.25, - 3323323.25, - 3328328.25, - 3333333.25, - 3338338.25, - 3343343.25, - 3348348.25, - 3353353.25, - 3358358.25, - 3363363.25, - 3368368.25, - 3373373.25, - 3378378.5, - 3383383.5, - 3388388.5, - 3393393.5, - 3398398.5, - 3403403.5, - 3408408.5, - 3413413.5, - 3418418.5, - 3423423.5, - 3428428.5, - 3433433.5, - 3438438.5, - 3443443.5, - 3448448.5, - 3453453.5, - 3458458.5, - 3463463.5, - 3468468.5, - 3473473.5, - 3478478.5, - 3483483.5, - 3488488.5, - 3493493.5, - 3498498.5, - 3503503.5, - 3508508.5, - 3513513.5, - 3518518.5, - 3523523.5, - 3528528.5, - 3533533.5, - 3538538.5, - 3543543.5, - 3548548.5, - 3553553.5, - 3558558.5, - 3563563.5, - 3568568.5, - 3573573.5, - 3578578.5, - 3583583.5, - 3588588.5, - 3593593.5, - 3598598.5, - 3603603.5, - 3608608.5, - 3613613.5, - 3618618.5, - 3623623.5, - 3628628.75, - 3633633.75, - 3638638.75, - 3643643.75, - 3648648.75, - 3653653.75, - 3658658.75, - 3663663.75, - 3668668.75, - 3673673.75, - 3678678.75, - 3683683.75, - 3688688.75, - 3693693.75, - 3698698.75, - 3703703.75, - 3708708.75, - 3713713.75, - 3718718.75, - 3723723.75, - 3728728.75, - 3733733.75, - 3738738.75, - 3743743.75, - 3748748.75, - 3753753.75, - 3758758.75, - 3763763.75, - 3768768.75, - 3773773.75, - 3778778.75, - 3783783.75, - 3788788.75, - 3793793.75, - 3798798.75, - 3803803.75, - 3808808.75, - 3813813.75, - 3818818.75, - 3823823.75, - 3828828.75, - 3833833.75, - 3838838.75, - 3843843.75, - 3848848.75, - 3853853.75, - 3858858.75, - 3863863.75, - 3868868.75, - 3873873.75, - 3878879, - 3883884, - 3888889, - 3893894, - 3898899, - 3903904, - 3908909, - 3913914, - 3918919, - 3923924, - 3928929, - 3933934, - 3938939, - 3943944, - 3948949, - 3953954, - 3958959, - 3963964, - 3968969, - 3973974, - 3978979, - 3983984, - 3988989, - 3993994, - 3998999, - 4004004, - 4009009, - 4014014, - 4019019, - 4024024, - 4029029, - 4034034, - 4039039, - 4044044, - 4049049, - 4054054, - 4059059, - 4064064, - 4069069, - 4074074, - 4079079, - 4084084, - 4089089, - 4094094, - 4099099, - 4104104, - 4109109, - 4114114, - 4119119, - 4124124, - 4129129.25, - 4134134.25, - 4139139.25, - 4144144.25, - 4149149.25, - 4154154.25, - 4159159.25, - 4164164.25, - 4169169.25, - 4174174.25, - 4179179.25, - 4184184.25, - 4189189.25, - 4194194.25, - 4199199, - 4204204, - 4209209, - 4214214, - 4219219, - 4224224, - 4229229, - 4234234, - 4239239, - 4244244, - 4249249, - 4254254.5, - 4259259.5, - 4264264.5, - 4269269.5, - 4274274.5, - 4279279.5, - 4284284.5, - 4289289.5, - 4294294.5, - 4299299.5, - 4304304.5, - 4309309.5, - 4314314.5, - 4319319.5, - 4324324.5, - 4329329.5, - 4334334.5, - 4339339.5, - 4344344.5, - 4349349.5, - 4354354.5, - 4359359.5, - 4364364.5, - 4369369.5, - 4374374.5, - 4379379.5, - 4384384.5, - 4389389.5, - 4394394.5, - 4399399.5, - 4404404.5, - 4409409.5, - 4414414.5, - 4419419.5, - 4424424.5, - 4429429.5, - 4434434.5, - 4439439.5, - 4444444.5, - 4449449.5, - 4454454.5, - 4459459.5, - 4464464.5, - 4469469.5, - 4474474.5, - 4479479.5, - 4484484.5, - 4489489.5, - 4494494.5, - 4499499.5, - 4504504.5, - 4509509.5, - 4514514.5, - 4519519.5, - 4524524.5, - 4529529.5, - 4534534.5, - 4539539.5, - 4544544.5, - 4549549.5, - 4554554.5, - 4559559.5, - 4564564.5, - 4569569.5, - 4574574.5, - 4579579.5, - 4584584.5, - 4589589.5, - 4594594.5, - 4599599.5, - 4604604.5, - 4609609.5, - 4614614.5, - 4619619.5, - 4624624.5, - 4629629.5, - 4634634.5, - 4639639.5, - 4644644.5, - 4649649.5, - 4654654.5, - 4659659.5, - 4664664.5, - 4669669.5, - 4674674.5, - 4679679.5, - 4684684.5, - 4689689.5, - 4694694.5, - 4699699.5, - 4704704.5, - 4709709.5, - 4714714.5, - 4719719.5, - 4724724.5, - 4729729.5, - 4734734.5, - 4739739.5, - 4744744.5, - 4749749.5, - 4754755, - 4759760, - 4764765, - 4769770, - 4774775, - 4779780, - 4784785, - 4789790, - 4794795, - 4799800, - 4804805, - 4809810, - 4814815, - 4819820, - 4824825, - 4829830, - 4834835, - 4839840, - 4844845, - 4849850, - 4854855, - 4859860, - 4864865, - 4869870, - 4874875, - 4879880, - 4884885, - 4889890, - 4894895, - 4899900, - 4904905, - 4909910, - 4914915, - 4919920, - 4924925, - 4929930, - 4934935, - 4939940, - 4944945, - 4949950, - 4954955, - 4959960, - 4964965, - 4969970, - 4974975, - 4979980, - 4984985, - 4989990, - 4994995 - ], - "xaxis": "x2", - "y": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 15.015625, - 265.265625, - 515.515625, - 765.765625, - 1016.015625, - 1266.265625, - 1516.5172119140625, - 1766.7672119140625, - 2017.0172119140625, - 2267.26708984375, - 2517.51708984375, - 2767.76708984375, - 3018.018798828125, - 3268.268798828125, - 3518.518798828125, - 3768.768798828125, - 4019.018798828125, - 4269.2685546875, - 4519.5185546875, - 4769.7705078125, - 5020.0205078125, - 5270.2705078125, - 5520.5205078125, - 5770.7705078125, - 6021.0205078125, - 6271.27197265625, - 6521.52197265625, - 6771.77197265625, - 7022.02197265625, - 7272.27197265625, - 7522.52197265625, - 7772.7734375, - 8023.0234375, - 8273.2734375, - 8523.5234375, - 8773.7734375, - 9024.0234375, - 9274.275390625, - 9524.525390625, - 9774.775390625, - 15025.025390625, - 15275.275390625, - 15525.525390625, - 15775.775390625, - 16026.0263671875, - 16276.275390625, - 16526.525390625, - 16776.77734375, - 17027.02734375, - 17277.27734375, - 17527.52734375, - 17777.77734375, - 18028.02734375, - 18278.27734375, - 18528.52734375, - 18778.77734375, - 19029.02734375, - 19279.27734375, - 19529.52734375, - 19779.78125, - 20030.03125, - 20280.28125, - 20530.53125, - 20780.78125, - 21031.03125, - 21281.28125, - 21531.53125, - 21781.78125, - 22032.03125, - 22282.28125, - 22532.53125, - 22782.78125, - 23033.03515625, - 23283.28515625, - 23533.53515625, - 23783.78515625, - 24034.03515625, - 24284.28515625, - 24534.53515625, - 24784.78515625, - 25035.03515625, - 25285.28515625, - 25535.53515625, - 25785.78515625, - 26036.037109375, - 26286.287109375, - 26536.537109375, - 26786.787109375, - 27037.037109375, - 27287.287109375, - 27537.537109375, - 27787.787109375, - 28038.037109375, - 28288.287109375, - 28538.537109375, - 28788.787109375, - 29039.037109375, - 29289.291015625, - 29539.541015625, - 29789.791015625, - 30040.041015625, - 30290.291015625, - 30540.541015625, - 30790.791015625, - 31041.041015625, - 31291.291015625, - 31541.541015625, - 31791.791015625, - 32042.041015625, - 32292.29296875, - 32542.54296875, - 32792.79296875, - 33043.04296875, - 33293.29296875, - 33543.54296875, - 33793.79296875, - 34044.04296875, - 34294.29296875, - 34544.54296875, - 34794.79296875, - 35045.04296875, - 35295.29296875, - 35545.546875, - 35795.796875, - 36046.046875, - 36342.59375, - 36843.09375, - 37343.59375, - 37844.09375, - 38344.59375, - 38845.09375, - 39345.59375, - 39846.09375, - 40346.59375, - 40847.1015625, - 41347.6015625, - 41848.1015625, - 42348.6015625, - 42849.1015625, - 43349.6015625, - 43850.1015625, - 44350.6015625, - 44851.1015625, - 45351.6015625, - 45852.1015625, - 46352.6015625, - 46853.1015625, - 47353.60546875, - 47854.10546875, - 48354.60546875, - 48855.1015625, - 49355.6015625, - 49856.1015625, - 50356.61328125, - 50857.11328125, - 51357.61328125, - 51858.11328125, - 52358.61328125, - 52859.11328125, - 53359.61328125, - 53860.11328125, - 54360.61328125, - 54861.11328125, - 55361.61328125, - 55862.11328125, - 56362.61328125, - 56863.11328125, - 57363.61328125, - 57864.11328125, - 58364.61328125, - 58865.11328125, - 59365.61328125, - 59866.11328125, - 60366.61328125, - 60867.11328125, - 61367.61328125, - 61868.11328125, - 62368.61328125, - 62869.125, - 63369.625, - 63870.125, - 64370.625, - 64871.125, - 65371.625, - 65872.125, - 66372.625, - 66873.125, - 67373.625, - 67874.125, - 68374.625, - 68875.125, - 69375.625, - 69876.125, - 70376.625, - 70877.125, - 71377.625, - 71878.125, - 72378.625, - 72879.125, - 73379.625, - 73880.125, - 74380.625, - 74881.125, - 75381.640625, - 75882.140625, - 76382.640625, - 76883.140625, - 77383.640625, - 77884.140625, - 78384.640625, - 78885.140625, - 79385.640625, - 79886.140625, - 80386.640625, - 80887.140625, - 81387.640625, - 81888.140625, - 82388.640625, - 82889.140625, - 83389.640625, - 83890.140625, - 84390.640625, - 84891.140625, - 85391.640625, - 85892.140625, - 86392.640625, - 86893.140625, - 87393.640625, - 87894.1484375, - 88394.6484375, - 88895.1484375, - 89395.6484375, - 89896.1484375, - 90396.6484375, - 90897.1484375, - 91397.6484375, - 91898.1484375, - 92398.6484375, - 92899.1484375, - 93399.6484375, - 93930.1796875, - 94530.78125, - 95131.3828125, - 95731.9765625, - 96332.578125, - 96933.1796875, - 97533.78125, - 98134.3828125, - 98734.9765625, - 99335.578125, - 99936.1796875, - 100536.78125, - 101137.3828125, - 101737.9921875, - 102338.59375, - 102939.1953125, - 103539.796875, - 104140.3984375, - 104740.9921875, - 105341.59375, - 105942.1953125, - 106542.796875, - 107143.3984375, - 107743.9921875, - 108344.59375, - 108945.1953125, - 109545.796875, - 110146.3984375, - 110746.9921875, - 111347.59375, - 111948.1953125, - 112548.796875, - 113149.3984375, - 113749.9921875, - 114350.59375, - 114951.1953125, - 115551.796875, - 116152.3984375, - 116753.0078125, - 117353.609375, - 117954.2109375, - 118554.8125, - 119155.40625, - 119756.0078125, - 120356.609375, - 120957.2109375, - 121557.8125, - 122158.40625, - 122759.0078125, - 123359.609375, - 123960.2109375, - 124560.8125, - 125161.40625, - 125762.0078125, - 126362.609375, - 126963.2109375, - 127563.8125, - 128164.40625, - 128765.0078125, - 129365.609375, - 129966.2109375, - 130566.8125, - 131167.40625, - 131768.03125, - 132368.625, - 132969.21875, - 133569.828125, - 134170.421875, - 134771.03125, - 135371.625, - 135972.21875, - 136572.828125, - 137173.421875, - 137774.03125, - 138374.625, - 138975.21875, - 139575.828125, - 140176.421875, - 140777.03125, - 141377.625, - 141978.21875, - 142578.828125, - 143179.421875, - 143780.03125, - 144380.625, - 144981.21875, - 145581.828125, - 146182.421875, - 146783.046875, - 147383.640625, - 147984.234375, - 148584.84375, - 149185.4375, - 149786.046875, - 150386.640625, - 150987.234375, - 151587.84375, - 152188.4375, - 152789.046875, - 153389.640625, - 153990.234375, - 154590.84375, - 155191.4375, - 155792.046875, - 156392.640625, - 156993.234375, - 157593.84375, - 158194.4375, - 158795.046875, - 159395.640625, - 159996.234375, - 160596.84375, - 161197.4375, - 161798.0625, - 162398.65625, - 162999.25, - 163599.859375, - 164200.453125, - 164801.0625, - 165401.65625, - 166002.234375, - 166602.84375, - 167203.4375, - 167804.046875, - 168404.640625, - 169005.265625, - 169605.875, - 170206.46875, - 170807.0625, - 171407.671875, - 172008.265625, - 172608.875, - 173209.46875, - 173810.0625, - 174410.671875, - 175011.265625, - 175611.875, - 176212.46875, - 176813.0625, - 177413.671875, - 178014.265625, - 178614.875, - 179215.46875, - 179816.0625, - 180416.671875, - 181017.265625, - 181617.875, - 182218.46875, - 182819.0625, - 183419.671875, - 184020.265625, - 184620.875, - 185221.46875, - 185822.0625, - 186422.671875, - 187023.265625, - 187623.875, - 188224.46875, - 188825.0625, - 189425.671875, - 190026.265625, - 190626.875, - 191227.46875, - 191828.0625, - 192428.671875, - 193029.265625, - 193629.875, - 194230.46875, - 194831.0625, - 195431.671875, - 196032.265625, - 196632.875, - 197233.46875, - 197834.0625, - 198434.671875, - 199035.296875, - 199635.90625, - 200236.5, - 200837.09375, - 201437.703125, - 202038.296875, - 202638.90625, - 203239.5, - 203840.09375, - 204440.703125, - 205041.296875, - 205641.90625, - 206242.5, - 206843.09375, - 207443.703125, - 208044.296875, - 208644.90625, - 209245.5, - 209846.09375, - 210446.703125, - 211047.296875, - 211647.90625, - 212248.5, - 212849.09375, - 213449.703125, - 214050.296875, - 214650.90625, - 215251.5, - 215852.09375, - 216452.703125, - 217053.296875, - 217653.90625, - 218254.5, - 218855.09375, - 219455.703125, - 220056.296875, - 220656.90625, - 221257.5, - 221858.09375, - 222458.703125, - 223059.296875, - 223659.90625, - 224260.5, - 224861.09375, - 225461.703125, - 226062.296875, - 226662.90625, - 227263.5, - 227864.09375, - 228464.703125, - 229065.328125, - 229665.9375, - 230266.53125, - 230867.125, - 231467.734375, - 232068.328125, - 232668.9375, - 233269.53125, - 233870.125, - 234470.734375, - 235071.328125, - 235671.9375, - 236272.53125, - 236873.125, - 237473.734375, - 238074.328125, - 238674.9375, - 239275.53125, - 239876.125, - 240476.734375, - 241077.328125, - 241677.9375, - 242278.53125, - 242879.125, - 243479.734375, - 244080.328125, - 244680.9375, - 245281.53125, - 245882.125, - 246482.734375, - 247083.328125, - 247683.9375, - 248284.53125, - 248885.125, - 249485.734375, - 250086.328125, - 250686.9375, - 251287.53125, - 251888.125, - 252488.734375, - 253089.328125, - 253689.9375, - 254290.53125, - 254891.125, - 255491.734375, - 256092.328125, - 256692.9375, - 257293.53125, - 257894.125, - 258494.734375, - 259095.359375, - 259695.953125, - 260296.5625, - 260897.15625, - 261497.765625, - 262098.359375, - 262698.96875, - 263299.5625, - 263900.15625, - 264500.75, - 265101.375, - 265701.96875, - 266302.5625, - 266903.15625, - 267503.75, - 268104.375, - 268704.96875, - 269305.5625, - 269906.15625, - 270506.75, - 271107.375, - 271707.96875, - 272308.5625, - 272909.15625, - 273509.75, - 274110.375, - 274710.96875, - 275311.5625, - 275912.15625, - 276512.75, - 277113.375, - 277713.96875, - 278314.5625, - 278915.15625, - 279515.75, - 280116.375, - 280716.96875, - 281317.5625, - 281918.15625, - 282518.75, - 283119.375, - 283719.96875, - 284320.5625, - 284921.15625, - 285521.75, - 286122.375, - 286722.96875, - 287323.5625, - 287924.15625, - 288524.75, - 289125.375, - 289726, - 290326.59375, - 290927.1875, - 291527.78125, - 292128.375, - 292729, - 293329.59375, - 293930.1875, - 294530.78125, - 295131.375, - 295732, - 296332.59375, - 296933.1875, - 297533.78125, - 298134.375, - 298735, - 299335.59375, - 299936.1875, - 300536.78125, - 301137.375, - 301738, - 302338.59375, - 302939.1875, - 303539.78125, - 304140.375, - 304741, - 305341.59375, - 305942.1875, - 306542.78125, - 307143.375, - 307744, - 308344.59375, - 308945.1875, - 309545.78125, - 310146.375, - 310747, - 311347.59375, - 311948.1875, - 312548.78125, - 313149.375, - 313750, - 314350.59375, - 314951.1875, - 315551.78125, - 316152.375, - 316753, - 317353.59375, - 317954.1875, - 318554.78125, - 319155.40625, - 319756.03125, - 320356.625, - 320957.21875, - 321557.8125, - 322158.40625, - 322759.03125, - 323359.625, - 323960.21875, - 324560.8125, - 325161.40625, - 325762.03125, - 326362.625, - 326963.21875, - 327563.8125, - 328164.40625, - 328765.03125, - 329365.625, - 329966.21875, - 330566.8125, - 331167.40625, - 331768.03125, - 332368.625, - 332969.21875, - 333569.8125, - 334170.40625, - 334771.03125, - 335371.625, - 335972.21875, - 336572.8125, - 337173.40625, - 337774.03125, - 338374.625, - 338975.21875, - 339575.8125, - 340176.40625, - 340777.03125, - 341377.625, - 341978.21875, - 342578.8125, - 343179.40625, - 343780.03125, - 344380.625, - 344981.21875, - 345581.8125, - 346182.40625, - 346783.03125, - 347383.625, - 347984.21875, - 348584.8125, - 349185.4375, - 349786.0625, - 350386.65625, - 350987.25, - 351587.84375, - 352188.4375, - 352789.0625, - 353389.65625, - 353990.25, - 354590.84375, - 355191.4375, - 355792.0625, - 356392.65625, - 356993.25, - 357593.84375, - 358194.4375, - 358795.0625, - 359395.65625, - 359996.25, - 360596.84375, - 361197.4375, - 361798.0625, - 362398.65625, - 362999.25, - 363599.84375, - 364200.4375, - 364801.0625, - 365401.65625, - 366002.25, - 366602.84375, - 367203.4375, - 367804.0625, - 368404.65625, - 369005.25, - 369605.84375, - 370206.4375, - 370807.0625, - 371407.65625, - 372008.25, - 372608.84375, - 373209.4375, - 373810.0625, - 374410.65625, - 375011.25, - 375611.84375, - 376212.4375, - 376813.0625, - 377413.65625, - 378014.25, - 378614.84375, - 379215.46875, - 379816.09375, - 380416.6875, - 381017.28125, - 381617.875, - 382218.46875, - 382819.09375, - 383419.6875, - 384020.28125, - 384620.875, - 385221.46875, - 385822.09375, - 386422.6875, - 387023.28125, - 387623.875, - 388224.46875, - 388825.09375, - 389425.6875, - 390026.28125, - 390626.875, - 391227.46875, - 391828.09375, - 392428.6875, - 393029.28125, - 393629.875, - 394230.46875, - 394831.09375, - 395431.6875, - 396032.28125, - 396632.875, - 397233.46875, - 397834.09375, - 398434.6875, - 399035.28125, - 399635.875, - 400236.46875, - 400837.09375, - 401437.6875, - 402038.28125, - 402638.875, - 403239.46875, - 403840.09375, - 404440.6875, - 405041.28125, - 405641.875, - 406242.46875, - 406843.09375, - 407443.6875, - 408044.28125, - 408644.875, - 409245.5, - 409846.125, - 410446.71875, - 411047.3125, - 411647.90625, - 412248.5, - 412849.125, - 413449.71875, - 414050.3125, - 414650.90625, - 415251.5, - 415852.125, - 416452.71875, - 417053.3125, - 417653.875, - 418254.46875, - 418855.09375, - 419455.6875, - 420056.28125, - 420656.875, - 421257.46875, - 421858.09375, - 422458.6875, - 423059.28125, - 423659.875, - 424260.53125, - 424861.125, - 425461.75, - 426062.34375, - 426662.9375, - 427263.53125, - 427864.125, - 428464.75, - 429065.34375, - 429665.9375, - 430266.53125, - 430867.125, - 431467.75, - 432068.34375, - 432668.9375, - 433269.53125, - 433870.125, - 434470.75, - 435071.34375, - 435671.9375, - 436272.53125, - 436873.125, - 437473.75, - 438074.34375, - 438674.9375, - 439275.53125, - 439876.125, - 440476.75, - 441077.34375, - 441677.9375, - 442278.53125, - 442879.125, - 443479.75, - 444080.34375, - 444680.9375, - 445281.53125, - 445882.125, - 446482.75, - 447083.34375, - 447683.9375, - 448284.53125, - 448885.125, - 449485.75, - 450086.34375, - 450686.9375, - 451287.53125, - 451888.125, - 452488.75, - 453089.34375, - 453689.9375, - 454290.53125, - 454891.125, - 455491.75, - 456092.34375, - 456692.9375, - 457293.53125, - 457894.125, - 458494.75, - 459095.34375, - 459695.9375, - 460296.53125, - 460897.125, - 461497.75, - 462098.34375, - 462698.9375, - 463299.53125, - 463900.125, - 464500.75, - 465101.34375, - 465701.9375, - 466302.53125, - 466903.125, - 467503.75, - 468104.34375, - 468704.9375, - 469305.53125, - 469906.125, - 470506.75, - 471107.34375, - 471707.9375, - 472308.53125, - 472909.125, - 473509.75, - 474110.34375, - 474710.9375, - 475311.53125, - 475912.125, - 476512.75, - 477113.34375, - 477713.9375, - 478314.53125, - 478915.125, - 479515.75, - 480116.34375, - 480716.9375, - 481317.53125, - 481918.125, - 482518.75, - 483119.34375, - 483719.9375, - 484320.59375, - 484921.1875, - 485521.8125, - 486122.40625, - 486723, - 487323.59375, - 487924.1875, - 488524.8125, - 489125.40625, - 489726, - 490326.59375, - 490927.1875, - 491527.8125, - 492128.40625, - 492729, - 493329.59375, - 493930.1875, - 494530.8125, - 495131.40625, - 495732, - 496332.59375, - 496933.1875, - 497533.8125, - 498134.40625, - 498735, - 499335.59375, - 499936.1875, - 500536.8125, - 501137.40625, - 501738, - 502338.59375, - 502939.1875, - 503539.8125, - 504140.40625, - 504741, - 505341.59375, - 505942.1875, - 506542.8125, - 507143.40625, - 507744, - 508344.59375, - 508945.1875, - 509545.8125, - 510146.40625, - 510747, - 511347.59375, - 511948.1875, - 512548.8125, - 513149.40625 - ], - "yaxis": "y2" - } - ], - "layout": { - "annotations": [ - { - "font": { - "size": 16 - }, - "showarrow": false, - "text": "Marginal SDLT rate", - "x": 0.225, - "xanchor": "center", - "xref": "paper", - "y": 1, - "yanchor": "bottom", - "yref": "paper" - }, - { - "font": { - "size": 16 - }, - "showarrow": false, - "text": "Total SDLT", - "x": 0.775, - "xanchor": "center", - "xref": "paper", - "y": 1, - "yanchor": "bottom", - "yref": "paper" - } - ], - "font": { - "color": "black", - "family": "Roboto Serif" - }, - "height": 600, - "images": [ - { - "sizex": 0.2, - "sizey": 0.2, - "source": "https://raw.githubusercontent.com/PolicyEngine/policyengine-app/master/src/images/logos/policyengine/blue.png", - "x": 1, - "xanchor": "right", - "xref": "paper", - "y": -0.15, - "yanchor": "bottom", - "yref": "paper" - } - ], - "modebar": { - "bgcolor": "rgba(0,0,0,0)", - "color": "rgba(0,0,0,0)" - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "white", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "#C8D4E3", - "linecolor": "#C8D4E3", - "minorgridcolor": "#C8D4E3", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "heatmapgl": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmapgl" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "white", - "showlakes": true, - "showland": true, - "subunitcolor": "#C8D4E3" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "white", - "polar": { - "angularaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - }, - "bgcolor": "white", - "radialaxis": { - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "yaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - }, - "zaxis": { - "backgroundcolor": "white", - "gridcolor": "#DFE8F3", - "gridwidth": 2, - "linecolor": "#EBF0F8", - "showbackground": true, - "ticks": "", - "zerolinecolor": "#EBF0F8" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "baxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - }, - "bgcolor": "white", - "caxis": { - "gridcolor": "#DFE8F3", - "linecolor": "#A2B1C6", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "#EBF0F8", - "linecolor": "#EBF0F8", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "#EBF0F8", - "zerolinewidth": 2 - } - } - }, - "width": 800, - "xaxis": { - "anchor": "y", - "domain": [ - 0, - 0.45 - ], - "tickprefix": "£", - "title": { - "text": "Price" - } - }, - "xaxis2": { - "anchor": "y2", - "domain": [ - 0.55, - 1 - ], - "tickprefix": "£", - "title": { - "text": "Price" - } - }, - "yaxis": { - "anchor": "x", - "domain": [ - 0, - 1 - ], - "tickformat": ",.0%" - }, - "yaxis2": { - "anchor": "x2", - "domain": [ - 0, - 1 - ], - "tickprefix": "£" - } - } - } + "text/plain": " Purchase price SDLT\n0 625000 10000.0\n1 625020 18751.0", + "text/html": "
    \n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    Purchase priceSDLT
    062500010000.0
    162502018751.0
    \n
    " }, - "metadata": {}, - "output_type": "display_data" + "execution_count": 2 } ], "source": [ "from policyengine_uk import Simulation\n", "\n", - "sim = Simulation(\n", - " situation=dict(\n", - " households=dict(\n", - " household=dict(\n", - " main_residential_property_purchased_is_first_home=True,\n", - " members=[\"person\"],\n", - " )\n", - " ),\n", - " people=dict(\n", - " person=dict(\n", - " age=30,\n", - " )\n", - " ),\n", - " axes=[\n", - " [\n", - " dict(\n", - " name=\"main_residential_property_purchased\",\n", - " min=0,\n", - " max=5_000_000,\n", - " count=1_000,\n", - " )\n", - " ]\n", - " ],\n", - " )\n", - ")\n", - "\n", - "import pandas as pd\n", - "\n", - "stamp_duty = sim.calculate(\"stamp_duty_land_tax\")\n", - "home_price = sim.calculate(\"main_residential_property_purchased\")\n", - "\n", - "marginal_rate = (stamp_duty[1:] - stamp_duty[:-1]) / (home_price[1:] - home_price[:-1])\n", - "home_price = home_price[:-1]\n", - "\n", - "df = pd.DataFrame(\n", + "prices = [625000, 625020]\n", + "situation = {\"people\": {}, \"benunits\": {}, \"households\": {}}\n", + "for i, price in enumerate(prices):\n", + " person = f\"buyer{i}\"\n", + " situation[\"people\"][person] = {\"age\": {2023: 30}}\n", + " situation[\"benunits\"][f\"family{i}\"] = {\"members\": [person]}\n", + " situation[\"households\"][f\"home{i}\"] = {\n", + " \"members\": [person],\n", + " \"region\": {2023: \"LONDON\"},\n", + " \"main_residential_property_purchased\": {2023: price},\n", + " \"main_residential_property_purchased_is_first_home\": {2023: True},\n", + " }\n", + "sim = Simulation(situation=situation)\n", + "pd.DataFrame(\n", " {\n", - " \"Home price\": home_price,\n", - " \"Marginal SDLT rate\": marginal_rate,\n", - " \"SDLT\": stamp_duty[:-1],\n", + " \"Purchase price\": prices,\n", + " \"SDLT\": sim.calculate(\"stamp_duty_land_tax\", 2023),\n", " }\n", - ")\n", - "\n", - "import plotly.express as px\n", - "from plotly.subplots import make_subplots\n", - "\n", - "# left shows marginal rate, right shows total SDLT\n", - "\n", - "fig = make_subplots(rows=1, cols=2, subplot_titles=(\"Marginal SDLT rate\", \"Total SDLT\"))\n", - "\n", - "fig.add_trace(\n", - " px.line(df, x=\"Home price\", y=\"Marginal SDLT rate\", title=\"Marginal SDLT rate\")\n", - " .update_traces(line_shape=\"hv\")\n", - " .data[0],\n", - " row=1,\n", - " col=1,\n", - ").update_xaxes(row=1, col=1, title_text=\"Price\", tickprefix=\"£\").update_yaxes(\n", - " row=1, col=1, tickformat=\",.0%\"\n", - ")\n", - "\n", - "fig.add_trace(\n", - " px.line(df, x=\"Home price\", y=\"SDLT\", title=\"Total SDLT\")\n", - " .update_traces(line_shape=\"hv\")\n", - " .data[0],\n", - " row=1,\n", - " col=2,\n", - ").update_xaxes(row=1, col=2, title_text=\"Price\", tickprefix=\"£\").update_yaxes(\n", - " row=1, col=2, tickprefix=\"£\"\n", - ")\n", - "\n", - "fig = format_fig(fig)\n", - "fig" + ")" ] }, { diff --git a/docs/book/programs/gov/ofgem/warm-home-discount-plan.md b/docs/book/programs/gov/ofgem/warm-home-discount-plan.md new file mode 100644 index 0000000000..151871ce26 --- /dev/null +++ b/docs/book/programs/gov/ofgem/warm-home-discount-plan.md @@ -0,0 +1,136 @@ +# Warm Home Discount (planned) + +```{warning} +**Not yet modelled.** The Warm Home Discount (WHD) is a £150 rebate off +a household's electricity bill each winter, paid by energy suppliers +under schemes that Ofgem administers. PolicyEngine UK doesn't model it. +This page sets out the proposed scope, tracked in +[#502](https://github.com/PolicyEngine/policyengine-uk/issues/502). +Updated October 2026: in England and Wales eligibility is now based on +benefit receipt alone, which makes the scheme much easier to model than +when this page was first drafted. +``` + +## The scheme now + +### England and Wales + +[The Warm Home Discount (England and Wales) Regulations 2026](https://www.legislation.gov.uk/uksi/2026/389/contents) +(SI 2026/389) continue the scheme from 1 April 2026 to 31 March 2031. +They replace the +[2022 Regulations](https://www.legislation.gov.uk/uksi/2022/772/contents) +(SI 2022/772), which applied to both England and Wales. + +Under the 2022 Regulations, the means-tested group had to pass a +"high cost to heat" test based on property data. For winter 2025-26 +that test was removed, so every household on a qualifying means-tested +benefit became eligible. The government estimated this would bring +2.7 million more households into the scheme, taking it to more than +6 million households in Great Britain +([government response, June 2025](https://assets.publishing.service.gov.uk/media/6852e6e9679778c74ec15e82/expanding-the-warm-home-discount-scheme-2025-to-2026-government-response.pdf)). + +For winter 2026-27, GOV.UK +([eligibility in England and Wales](https://www.gov.uk/the-warm-home-discount-scheme/if-you-live-in-england-and-wales)) +says a household qualifies if, on the qualifying date of 23 August +2026, its electricity supplier is in the scheme, the person's (or +their partner's) name is on the electricity bill, and the person or +their partner gets one of: + +- Universal Credit; +- Housing Benefit; +- income-related Employment and Support Allowance; +- Pension Credit. + +The rebate is applied automatically. + +### Scotland + +The scheme runs under separate Scottish regulations. +[The Warm Home Discount (Scotland) Regulations 2026](https://www.legislation.gov.uk/uksi/2026/488/contents) +(SI 2026/488) came into force on 1 May 2026 and run the scheme to +31 March 2031, replacing the +[2022 Regulations](https://www.legislation.gov.uk/uksi/2022/1073/contents) +(SI 2022/1073). Both 2026 Regulations set the rebate at £150. For winter 2026-27 +([eligibility in Scotland](https://www.gov.uk/the-warm-home-discount-scheme/if-you-live-in-scotland)): + +- **Core group, automatic:** Pension Credit; Universal Credit with an + extra amount for a disability or health condition; income-related ESA + with a disability or pensioner premium; or a Support for Mortgage + Interest loan with one of those premiums. Also people responsible for + a child under 5 who are unemployed and on Universal Credit or + income-related ESA, or have a Support for Mortgage Interest loan. +- **Broader group, by application** to the energy supplier, which can + set its own extra criteria. + +The qualifying date is also 23 August 2026. + +### Northern Ireland + +The scheme doesn't operate in Northern Ireland: its primary legislation +extends to Great Britain only. + +## Proposed scope + +### Phase 1: England and Wales + +Now a rule on benefit receipt: + +- Parameter `gov.ofgem.warm_home_discount.amount`: £150, the + "prescribed rebate" in both sets of 2026 Regulations (£140 before + winter 2022-23). +- Parameter listing the qualifying benefits, so a reform can widen or + narrow the list. +- Variable `warm_home_discount` (Household, YEAR): the amount if the + household is in England or Wales and any benefit unit receives a + qualifying benefit. Read receipt from the modelled benefits, for + example `universal_credit`, `housing_benefit`, `esa_income` and + `pension_credit`, so that take-up carries through. +- No separate take-up flag, since the rebate is paid without a claim. + +### Phase 2: Scotland core group + +The core group can be built from the same benefit variables plus the +Universal Credit disability elements, the ESA premiums and the age of +the youngest child. + +### Phase 3: Scotland broader group + +Application-based, so it needs a take-up rate calibrated to Ofgem's +published recipient numbers for Scotland, on top of the eligible group. + +## Data and calibration + +- Ofgem's [Warm Home Discount pages](https://www.ofgem.gov.uk/environmental-and-social-schemes/warm-home-discount-whd): + recipients and spend by group and country. +- DWP benefit caseloads, already used to calibrate the qualifying + benefits. +- The 2025-26 impact assessment and government response give the + expected number of recipients after the expansion. Use them to check + Phase 1's aggregate. + +## Open questions + +- **Timing.** The scheme year runs from October to March, with the + qualifying date in August. A model year (April to March) contains one + winter's rebate, so pay the rebate in the year that contains the + winter. +- **Who counts as the household.** The rebate attaches to an + electricity account. In multi-benefit-unit households, decide whether + one qualifying benefit unit is enough (simplest, and likely right for + most households) and say so. +- **Electricity VAT interaction.** The rebate reduces the bill. If the + model ever nets it off energy spending, it would interact with the + domestic electricity VAT work in + [#2189](https://github.com/PolicyEngine/policyengine-uk/pull/2189). + Modelled as a benefit, it doesn't. + +## References + +- [The Warm Home Discount (England and Wales) Regulations 2026 (SI 2026/389)](https://www.legislation.gov.uk/uksi/2026/389/contents). +- [The Warm Home Discount (England and Wales) Regulations 2022 (SI 2022/772)](https://www.legislation.gov.uk/uksi/2022/772/contents). +- [The Warm Home Discount (Scotland) Regulations 2026 (SI 2026/488)](https://www.legislation.gov.uk/uksi/2026/488/contents). +- [The Warm Home Discount (Scotland) Regulations 2022 (SI 2022/1073)](https://www.legislation.gov.uk/uksi/2022/1073/contents). +- DESNZ, [Expanding the Warm Home Discount scheme, 2025 to 2026: government response](https://assets.publishing.service.gov.uk/media/6852e6e9679778c74ec15e82/expanding-the-warm-home-discount-scheme-2025-to-2026-government-response.pdf). +- GOV.UK, [Warm Home Discount Scheme](https://www.gov.uk/the-warm-home-discount-scheme). +- Ofgem, [Warm Home Discount scheme pages and reports](https://www.ofgem.gov.uk/environmental-and-social-schemes/warm-home-discount-whd). +- Issue: [#502](https://github.com/PolicyEngine/policyengine-uk/issues/502). diff --git a/docs/book/usage/dynamics.md b/docs/book/usage/dynamics.md index a29777a531..80bad9b884 100644 --- a/docs/book/usage/dynamics.md +++ b/docs/book/usage/dynamics.md @@ -10,7 +10,11 @@ PolicyEngine UK implements the Office for Budget Responsibility's labour supply **Extensive margin (participation)** - Whether people choose to work at all. Changes in the financial reward from working affect decisions to enter or leave employment. -Both responses depend on elasticities that vary by demographic group, following empirical evidence on how different populations respond to policy changes. +Both responses depend on elasticities that vary by demographic group. The +current `apply_dynamics` coordinator runs progression only: it leaves +participation disabled and returns `participation=None`. The separate +participation function remains available for controlled development work; the +coordinator does not combine its response with progression. ## Basic dynamics calculation @@ -46,7 +50,10 @@ print( f"Substitution effect FTEs: {dynamics.fte_impacts.substitution_response_ftes:,.0f}" ) print(f"Income effect FTEs: {dynamics.fte_impacts.income_response_ftes:,.0f}") -print(f"Participation FTEs: {dynamics.fte_impacts.participation_response_ftes:,.0f}") +if dynamics.fte_impacts.participation_response_ftes is not None: + print( + f"Participation FTEs: {dynamics.fte_impacts.participation_response_ftes:,.0f}" + ) print(f"Total FTE change: {dynamics.fte_impacts.ftes:,.0f}") ``` @@ -64,14 +71,37 @@ print(f"Average hours change: {progression['total_response_ftes'].mean():.3f} FT # Access participation responses (extensive margin) participation = dynamics.participation -print(f"Employment change: {participation['participation_change'].sum():,.0f}") +if participation is not None: + print(f"Employment change: {participation['participation_change'].sum():,.0f}") # FTE impacts provide summary statistics fte = dynamics.fte_impacts print(f"Total labour supply response: {fte.ftes:,.0f} FTEs") ``` -The progression DataFrame contains individual-level responses including wage changes, elasticities applied, and resulting employment income adjustments. The participation DataFrame shows employment status changes and associated FTE impacts. +The progression DataFrame contains individual-level responses including wage +changes, elasticities applied and resulting employment income adjustments. +Participation results are absent from the current coordinator. + +### Separate participation calls + +`policyengine_uk.dynamics.participation.apply_participation_responses` accepts +an explicit year for demographics, gain-to-work counterfactuals and earnings/hours +writeback. `calculate_participation_elasticities` also accepts a year; omitting it +uses the simulation default. These calls use the weighted `Microsimulation` +interface, including situations supplied without a population dataset. + +Partnership groups include cohabiting couples, as corrected in +[PR #2445](https://github.com/PolicyEngine/policyengine-uk/pull/2445). Spillover +bins use eligible response candidates only. The existing coefficients, 20 bins, +unweighted within-bin averages and spillover assumptions remain unchanged. + +A local `numpy.random.RandomState` preserves the seeded MT19937 draw order: +ascending worker rows, then ascending non-worker rows, including zero-probability +and nonpositive-wage candidates. It leaves NumPy's global random state unchanged. +Entrants' weekly hours use `WEEKS_IN_YEAR` to produce annual hours. The current +coordinator still returns no participation results; cost and performance work +remains open in [issue #1836](https://github.com/PolicyEngine/policyengine-uk/issues/1836). ## Analysing distributional effects @@ -155,7 +185,10 @@ print(f"OBR estimate: 94,000") print(f"Difference: {(dynamics.fte_impacts.ftes - 94_000):+,.0f}") ``` -Small differences from OBR estimates can arise from data vintage, population projections, or minor methodological variations. +This example runs the current progression-only coordinator. It is not a +validation of the disabled participation route or a promise of agreement with +the OBR total. Data, grouping, imputation and calibration choices also affect +the comparison. ## Elasticity-based responses @@ -238,4 +271,6 @@ When using dynamics, be aware of these limitations: - Self-employed workers are excluded as their labour supply is harder to model - The model doesn't capture long-term effects like human capital accumulation -For most tax and benefit reforms, these limitations don't significantly affect the results. The framework provides robust estimates aligned with fiscal policy analysis standards. \ No newline at end of file +The size of these limitations has not been established for every reform. +Participation calibration and broader performance work remain outside these +implementation corrections. \ No newline at end of file diff --git a/docs/book/validation/child-poverty-tcl.md b/docs/book/validation/child-poverty-tcl.md new file mode 100644 index 0000000000..8948ebf2bd --- /dev/null +++ b/docs/book/validation/child-poverty-tcl.md @@ -0,0 +1,120 @@ +# Child poverty validation: removing the two-child limit (2029-30) + +This page compares PolicyEngine UK's estimate of the child-poverty +impact and fiscal cost of removing the [Universal Credit two-child +limit][gov-2cl] against the published estimates from the Department for +Work and Pensions, the Office for Budget Responsibility and the Resolution +Foundation. Budget 2025 announced the removal with effect from 6 April +2026, which the +`gov.dwp.universal_credit.elements.child.limit.child_count` parameter +encodes (`.inf` from `2026-04-06`). + +Tracks [#1398](https://github.com/PolicyEngine/policyengine-uk/issues/1398). + +## Results + +| Source | Children lifted from poverty (2029-30) | Treasury cost (2029-30) | +|--------|----------------------------------------:|------------------------:| +| [DWP poverty impact assessment][gov-2cl] (children); [Budget 2025, Table 1.2][budget-2025] (cost, OBR-certified) | 450,000 (AHC); 300,000 (BHC) | £3.1 bn | +| [Resolution Foundation *No half measures*][rf-nhm] | 480,000 (AHC) | £3.5 bn | +| **PolicyEngine UK, Microcosm UK 2024-25 (AHC)** | **~463,000** | **£3.26 bn** | +| **PolicyEngine UK, Microcosm UK 2024-25 (BHC)** | **~336,000** | (same) | +| PolicyEngine UK, Enhanced FRS 2022-23 (AHC), May 2026 | ~501,000 | £3.40 bn | +| PolicyEngine UK, Enhanced FRS 2022-23 (BHC), May 2026 | ~415,000 | (same) | + +The October 2026 run uses the Microcosm UK 2024-25 national release +(SHA-256 `aa31bdf6…`) on policyengine-uk 2.123.7. On that dataset, +PolicyEngine's after-housing-costs headcount sits between the DWP and +Resolution Foundation figures, its before-housing-costs headcount is +above DWP's, and its cost sits between the Budget 2025 and Resolution +Foundation figures. The May 2026 Enhanced FRS run, kept for comparison, +was above both on headcount. + +## How the estimate was produced + +```python +from policyengine_uk import Microsimulation + +# Local path to the Microcosm UK 2024-25 national release file. +DATASET = "microcosm_uk_2024_25.h5" +YEAR = 2029 + +# Current law baseline (TCL removed from 2026-04-06 per Budget 2025) +sim_baseline = Microsimulation(dataset=DATASET) + +# Counterfactual where the TCL is held in place at 2 children +sim_counter = Microsimulation( + dataset=DATASET, + reform={ + "gov.dwp.universal_credit.elements.child.limit.child_count": { + f"{YEAR}-01-01": 2, + }, + }, +) + +is_child = sim_baseline.calculate("is_child", YEAR) +in_poverty_baseline = sim_baseline.calculate( + "in_poverty_ahc", YEAR, map_to="person" +).astype(float) +in_poverty_counter = sim_counter.calculate( + "in_poverty_ahc", YEAR, map_to="person" +).astype(float) + +children_lifted = ((in_poverty_counter - in_poverty_baseline) * is_child).sum() +fiscal_cost = ( + sim_counter.calculate("gov_balance", YEAR).sum() + - sim_baseline.calculate("gov_balance", YEAR).sum() +) + +print(f"AHC children lifted: {children_lifted / 1e3:.0f}k") +print(f"Treasury cost: £{fiscal_cost / 1e9:.2f}bn") +``` + +This is a *reverse* comparison: PolicyEngine's baseline already includes +the TCL removal (the parameter flips to infinity from 2026-04-06), so the +counterfactual *re-imposes* the limit at 2 children to compute the +2029-30 effect. The signs cancel out: the children-lifted figure is +positive (baseline has fewer in poverty than counterfactual) and the +Treasury cost is positive (baseline collects less revenue than the +counterfactual would). + +## Why the estimates differ + +Three plausible drivers of the remaining differences, and of the move +between the Enhanced FRS and Microcosm runs: + +1. **Nowcasting methodology**. RF and DWP/OBR use somewhat different + earnings, rent, and benefit-uprating paths through 2029-30. See the + [PolicyEngine vs RF comparison](../assumptions/nowcasting-comparison.md) + and the [RF methodology detail](../assumptions/rf-nowcasting-methodology.md) + for the specific divergence points. +2. **Take-up assumptions**. PolicyEngine applies the same Universal + Credit take-up (`would_claim_uc`, set in the data) to families gaining + entitlement from the removal as to existing claimants. A source that + assumes lower take-up among newly entitled families would show a + smaller headcount and cost. +3. **Data and calibration vintage**. The DWP, OBR and RF estimates were + finalised against November 2025 assumptions; PolicyEngine's parameters tick + over with each EFO release. The Microcosm release is built from FRS + 2024-25 rather than 2022-23, with take-up anchored on reported receipt. + +## How to keep this validation alive + +- Re-run the snippet on each new Microcosm UK release, and when the + remaining `would_claim_*` conversions (#1621) land. Both could move the + AHC poverty rate by a non-trivial amount. +- After each new EFO release, update the per-year poverty headcounts and + the fiscal cost table. + +## References + +- HM Treasury, [Budget 2025][budget-2025], Table 1.2: the OBR-certified cost of removing the two-child limit (£3.1 billion in 2029-30). +- OBR, [Economic and Fiscal Outlook November 2025][obr-efo]. +- Resolution Foundation, [No half measures: ending the two-child limit][rf-nhm]. +- DWP, [Removing the two-child limit on Universal Credit: impact on low income poverty levels in the United Kingdom][gov-2cl], Tables 1 and 2 (financial year ending 2030). +- Issue: [#1398](https://github.com/PolicyEngine/policyengine-uk/issues/1398). Related: [#1171](https://github.com/PolicyEngine/policyengine-uk/issues/1171), [#1388](https://github.com/PolicyEngine/policyengine-uk/issues/1388). + +[obr-efo]: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ +[budget-2025]: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html +[rf-nhm]: https://www.resolutionfoundation.org/publications/no-half-measures/ +[gov-2cl]: https://www.gov.uk/government/publications/poverty-impacts-of-social-security-changes-at-budget-2025/removing-the-two-child-limit-on-universal-credit-impact-on-low-income-poverty-levels-in-the-united-kingdom diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index f9ccfc6609..557ea35f81 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -108,6 +108,6 @@ Each of the variables above is either derived from other variables, or is a dire | `maintenance_expenses` | gov.obr.consumer_price_index | Uprated based on CPI inflation | | `student_loan_repayments` | gov.economic_assumptions.indices.obr.average_earnings | Uprated based on OBR average earnings projections | | `rent` | gov.obr.rent | Social rents uprated by CPI+1%, private rents uprated with outturn data by region to 2025, then with backed-out private rent level growth from OBR aggregate rent forecasts (assuming social rent grows at CPI+1%) | -| `water_and_sewerage_charges` | gov.obr.consumer_price_index | Uprated based on CPI as proxy | +| `water_and_sewerage_charges` | gov.economic_assumptions.yoy_growth.ofwat.water_bills (England and Wales), gov.economic_assumptions.yoy_growth.scottish_water.water_bills (Scotland) | Uprated by each country's published bill increases, then projections; Northern Ireland has no domestic water charges and is held flat | | `mortgage_interest_repayment` | gov.obr.mortgage_interest | Uprated based on outturn Ofwat data to 2025, then Ofwat projections onwards | | `housing_service_charges` | gov.economic_assumptions.indices.obr.lagged_average_earnings | Uprated based on lagged average earnings as proxy | diff --git a/docs/book/validation/policyengine-py-household-impact.md b/docs/book/validation/policyengine-py-household-impact.md new file mode 100644 index 0000000000..1926c110cc --- /dev/null +++ b/docs/book/validation/policyengine-py-household-impact.md @@ -0,0 +1,119 @@ +# Household-impact tests returning 0 (resolved) + +```{note} +**Resolved.** [#1628](https://github.com/PolicyEngine/policyengine-uk/issues/1628) +reported that two `policyengine.py` household-impact integration tests +started asserting `0.0` for expected-positive tax and child-benefit +values after bumping the UK extra to `policyengine-uk==2.88.0`. This +page documents the diagnosis from the PolicyEngine UK side and the +guidance for the upstream `policyengine.py` repo. It is not a model +bug: the UK model produces the expected non-zero values when called +correctly. + +Re-checked in October 2026 on policyengine-uk 2.123.7: both scenarios +below still return £7,486 and £1,354.60. The upstream reproducer +(policyengine.py#280) was closed without merging. policyengine.py has +since replaced the `TestUKHouseholdImpact` tests with +`TestUKCalculateHousehold` in `tests/test_household_impact.py`, which +passes an explicit `year` to every case and pins policyengine-uk +2.125.1. #1628 can be closed once those tests are confirmed green. +``` + +## Reproduction from the UK side + +Running the two scenarios directly against `policyengine-uk` main: + +```python +from policyengine_uk import Simulation + +YEAR = 2025 + +# Scenario 1: single adult with employment income. +s1 = { + "people": {"adult": {"age": {YEAR: 30}, "employment_income": {YEAR: 50_000}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": {"household": {"members": ["adult"]}}, +} +sim = Simulation(situation=s1) +print("income_tax:", sim.calculate("income_tax", YEAR).sum()) +# -> 7486.00 + +# Scenario 2: couple with one child. +s2 = { + "people": { + "a1": {"age": {YEAR: 35}, "employment_income": {YEAR: 30_000}}, + "a2": {"age": {YEAR: 33}}, + "c1": {"age": {YEAR: 5}}, + }, + "benunits": {"benunit": {"members": ["a1", "a2", "c1"]}}, + "households": {"household": {"members": ["a1", "a2", "c1"]}}, +} +sim = Simulation(situation=s2) +print("child_benefit:", sim.calculate("child_benefit", YEAR).sum()) +# -> 1354.60 +``` + +Both return the expected non-zero values across the range 2022-2025 +(the income-tax figure is invariant because the personal allowance is +£12,570 throughout this window). So **the underlying model is healthy**. + +## What changed between 2.74 and 2.88 + +The change in that window that most plausibly explains the upstream +failure is the **dataset-required** invariant, added in 2.76.0 +("Stop defaulting to private dataset"): `Simulation()` with no +`situation=`, no explicit `dataset=` and no +`POLICYENGINE_UK_DEFAULT_DATASET` environment variable raises +`ValueError: Simulation() requires an explicit dataset when no +situation is provided`. If the `policyengine.py` harness was catching +that error and falling through to a degenerate code path (a +zero-filled `Microsimulation`), the tests would see zeros. + +The **default-period** contract is a second thing to rule out, although +it did not change in that window (both 2.74.0 and 2.88.0 default to +2025): + +```python +# policyengine_uk/simulation.py +class Simulation(CoreSimulation): + default_input_period: int = 2025 + default_calculation_period: int = 2025 +``` + +The failing tests built 2024 households. Any calculation that relied on +the default period would compute 2025, and any tax or benefit input +populated only for 2024 would be missing for that year. + +## Guidance for the upstream fix + +The `policyengine.py` household-impact harness should: + +1. Pass an **explicit `period`** to every `calculate(...)` call + matching the year encoded in the situation YAML, rather than relying + on the model default. +2. Build single-household cases with **`Simulation(situation=...)`** + rather than a dataset-backed simulation. They don't need a dataset + and shouldn't bring in the default-dataset env-var dependency. +3. **Assert against the year encoded in the test fixture**, not the + model's `default_input_period`. The fixture-encoded year is what the + test author intended. + +The two failing tests should produce ~£7,486 income tax for a +£50k-earning single adult and ~£1,354.60 of child benefit for a +couple-with-one-child benefit unit at 2025 rates. + +## Why this is documented in `policyengine-uk` rather than fixed there + +The actual fix lives in `policyengine.py`'s test harness and is +explicitly out of scope for this repo (sister-repo edits aren't +made from `policyengine-uk`). This page exists so the next reviewer +who sees `#1628` and is unfamiliar with the policyengine.py setup has +a one-stop diagnosis instead of having to re-derive it. + +## References + +- Issue: [#1628](https://github.com/PolicyEngine/policyengine-uk/issues/1628). +- Reproducer: [`PolicyEngine/policyengine.py#280`](https://github.com/PolicyEngine/policyengine.py/pull/280). +- Default-period contract: [`policyengine_uk/simulation.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/simulation.py) (`default_input_period`, `default_calculation_period`). +- Default-dataset env var contract: `POLICYENGINE_UK_DEFAULT_DATASET`, + defined in [`policyengine_uk/tax_benefit_system.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/tax_benefit_system.py). diff --git a/docs/book/validation/reform-impacts-dashboard-plan.md b/docs/book/validation/reform-impacts-dashboard-plan.md new file mode 100644 index 0000000000..316ace197c --- /dev/null +++ b/docs/book/validation/reform-impacts-dashboard-plan.md @@ -0,0 +1,174 @@ +# Reform impacts dashboard (planned) + +```{note} +**Planning page.** Tracks +[#806](https://github.com/PolicyEngine/policyengine-uk/issues/806), +which asks for a dashboard tracking the budgetary impact of +high-importance reforms over time and across model / data versions. +The repo already has the **fixtures**; what's missing is a published +view onto them. Updated October 2026: still not built, and the planned +dataset move to Microcosm UK (below) makes it more useful. +``` + +## What exists today + +[`policyengine_uk/tests/microsimulation/reforms_config.yaml`](../../../policyengine_uk/tests/microsimulation/reforms_config.yaml) +is a list of canonical reform-impact regression fixtures with the +shape: + +```yaml +- name: Raise basic rate by 1pp + expected_impact: 7.8 # £bn fiscal impact at the reference year + parameters: + gov.hmrc.income_tax.rates.uk[0].rate: 0.21 +- name: Reduce Universal Credit taper rate to 20% + expected_impact: -18.1 + tolerance: 3.0 + parameters: + gov.dwp.universal_credit.means_test.reduction_rate: 0.2 +``` + +Each fixture is run by +[`tests/microsimulation/test_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/test_reform_impacts.py), +which compares the change in `gov_balance` in 2029 with +`expected_impact` (£bn), within `tolerance` (default £1bn). The tests +are marked `microsimulation`, so they run in CI's test suite only when a +dataset is available: `policyengine_uk/tests/conftest.py` points +`POLICYENGINE_UK_DEFAULT_DATASET` at the Enhanced FRS 2023-24 file +(`enhanced_frs_2023_24.h5@1.40.3`) when a Hugging Face token is set. +The suite requires that data in the merge queue and on main, and skips +these tests on pull requests without credentials. So CI already +catches *unintended* impact changes before they reach main. But the +signal is only +pass or fail, and when a change is intended, someone reruns +[`update_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/update_reform_impacts.py) +and the old value is overwritten. The fixtures are regression values +taken from the model's own output, not external costings (the file says +so at the top). + +What's missing is a **time series**: the same eight reforms run on +every commit / version, plotted against the published OBR/RF +benchmark for the same reform, with annotations for the relevant +infrastructure changes (model versions, dataset refreshes, EFO +updates). + +## Proposed shape + +### Storage + +Each scheduled run writes a row per fixture to a CSV / Parquet on +HuggingFace Hub or a small Postgres instance: + +``` +timestamp, policyengine_uk_version, policyengine_uk_data_version, +dataset_url, reform_name, expected_impact, observed_impact, gap +``` + +The table is append-only, with no overwrites, so the history survives. + +### Computation + +A new entry point under `tests/microsimulation/` (or, better, a +GitHub Actions workflow that doesn't run inside the test suite) +walks `reforms_config.yaml`, runs each reform, and writes a row +per fixture. Schedule: weekly + on every release tag. + +### Publication + +Three publication paths, in order of effort: + +1. **Static page in the docs site**: the simplest. Convert the CSV + to a Plotly chart (matching the existing `validation/*.ipynb` + pattern) and render it under `docs/book/validation/`. Updates + when the docs site rebuilds. +2. **Living JSON endpoint**: push the latest aggregate JSON to a + stable URL (HuggingFace dataset or a `gh-pages` artefact) so the + policyengine.org app can render it without needing the docs build. +3. **policyengine.org dashboard tile**: a tile on the marketing site + that shows the latest impact alongside the OBR/RF benchmark. Most + work; right answer once the static page proves the data is stable. + +### What to put on the dashboard + +For each reform: + +- **Latest observed impact** (£bn, current model). +- **Published external benchmark** (OBR EFO costing where one + exists; RF *No half measures*-style costing as a comparator). Annotate + the publication date. +- **Time series** of observed impact across the last N model releases. +- **Annotations** for each known regime shift: EFO release, dataset + refresh, methodology PR. The reforms_config history is the natural + source of "what changed" annotations. + +## Initial reform list + +Use the existing `reforms_config.yaml` entries as the v1 dashboard +content: + +1. Raise basic rate by 1pp. +2. Raise higher rate by 1pp. +3. Raise the personal allowance to £13,000 (the fixture's name says + "by ~800GBP/year", but the allowance is frozen at £12,570 to + 2030-31, so in 2029 the rise is £430). +4. Set the child benefit rate for each additional child to £25 a week. +5. Reduce Universal Credit taper to 20%. +6. Raise Class 1 main employee NI rate to 10%. +7. Raise VAT standard rate by 2pp. +8. Raise additional rate by 3pp. + +These cover three of the four big revenue heads (income tax, NI, VAT) +plus a UC parameter that matters disproportionately for distributional +analysis. + +Add to v2 over time as published OBR / RF reforms become available +benchmarks: + +- Remove the two-child limit (an external comparison is drafted in + [#1724](https://github.com/PolicyEngine/policyengine-uk/pull/1724)). +- Freeze the Personal Allowance for an additional year. +- 1pp employer NI change. + +## Why now + +The UK data pipeline is moving from the Enhanced FRS to +[Microcosm UK](https://github.com/PolicyEngine/microcosm), while the +fixtures still run on the Enhanced FRS 2023-24. Moving the +fixtures' dataset will shift every expected impact at once, and model +changes to VAT, fuel duty and other bases are landing at the same time. +A history that records the dataset alongside each value is the easiest +way to show which change moved which number. + +## What this doesn't replace + +This is a **regression-tracking** dashboard, not a forecast. The +numbers it plots are PolicyEngine UK's reading of a reform under +current model + data + EFO assumptions; they will move when any of +those move, by design. The whole point of plotting the time series is +to make those movements legible rather than hidden. + +For comparison with external nowcasters (RF, IFS, OBR scoring), the +relevant pages are: + +- [nowcasting-comparison.md](../assumptions/nowcasting-comparison.md) (PolicyEngine vs RF). +- [rf-nowcasting-methodology.md](../assumptions/rf-nowcasting-methodology.md) (RF methodology detail). +- [#1724](https://github.com/PolicyEngine/policyengine-uk/pull/1724) (child poverty against OBR and Resolution Foundation benchmarks, in review). + +## Open questions + +- Storage substrate: HuggingFace dataset CSV vs a small managed + database. CSV is simplest; database lets the policyengine.org app + query directly. +- Scheduling: every commit to main, every weekly tag, or both? + Recommend weekly + on tag, since per-commit runs cost + microsim time without proportional information gain. +- Should the dashboard include reform variants from the policyengine + blog series, or stick to the parametric fixtures? Recommend + parametric fixtures only for v1 to keep maintenance burden low. + +## References + +- Issue: [#806](https://github.com/PolicyEngine/policyengine-uk/issues/806). +- Existing fixture list: [`reforms_config.yaml`](../../../policyengine_uk/tests/microsimulation/reforms_config.yaml). +- Existing regression runner: [`test_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/test_reform_impacts.py). +- Script that rewrites the expected values: [`update_reform_impacts.py`](../../../policyengine_uk/tests/microsimulation/update_reform_impacts.py). diff --git a/docs/book/validation/ubuntu-vs-macos.md b/docs/book/validation/ubuntu-vs-macos.md new file mode 100644 index 0000000000..8f2d867ad2 --- /dev/null +++ b/docs/book/validation/ubuntu-vs-macos.md @@ -0,0 +1,98 @@ +# Ubuntu vs macOS reproducibility + +```{note} +Issue [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209) +reported that PolicyEngine UK produced different aggregates on +`ubuntu-latest` and `macos-latest` for the same dataset and the same +`policyengine-uk` version. The cause was in parameter uprating. As of +October 2026 the parameter tree is byte-identical across macOS and +Linux, and CI has moved back to Linux. This page records the diagnosis, +the fix and how to check for a regression. +``` + +## Symptom + +The divergence was first spotted in +[#1205](https://github.com/PolicyEngine/policyengine-uk/pull/1205), +whose results differed between the two runners. A diagnostic +repository that ran the same code on both runners and stored the full +entity dataset as artifacts showed that: + +- the input data were identical on both operating systems; +- a limited subset of parameters was uprated on macOS but not on + Ubuntu (the parameter-tree dumps are attached to + [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209#issuecomment-3062418736)). + +The effect on weighted aggregates was small, but it broke +reproducibility. + +## Cause + +[#1223](https://github.com/PolicyEngine/policyengine-uk/pull/1223) +(closed without merging) traced it to an **import-time ordering +problem**: the uprating growth-factor table +(`BASELINE_GROWFACTORS = create_policyengine_uprating_factors_table()`) +was computed when its module was imported, before the parameter tree +had been fully processed. Whether the tree was ready by then depended +on the order in which modules and parameter files were loaded, and that +order differed between the two operating systems. + +## How it was resolved + +- [#1092](https://github.com/PolicyEngine/policyengine-uk/pull/1092) + and [#1207](https://github.com/PolicyEngine/policyengine-uk/pull/1207) + ran the Test job on `macos-latest` in 2025, so that CI matched the + platform that gave the expected results. +- [#1254](https://github.com/PolicyEngine/policyengine-uk/pull/1254) + (multi-year datasets, with uprating as a separate step) removed the + import-time `BASELINE_GROWFACTORS` table. Uprating is now applied by + `apply_uprating` in + [`policyengine_uk/data/economic_assumptions.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/data/economic_assumptions.py). +- Later fixes to the same path: + [#1523](https://github.com/PolicyEngine/policyengine-uk/pull/1523) + (no redundant dataset copy in `apply_uprating`), + [#1543](https://github.com/PolicyEngine/policyengine-uk/pull/1543) and + [#1544](https://github.com/PolicyEngine/policyengine-uk/pull/1544) + (`uprate_rent` lookups). +- policyengine-core now sorts each directory listing when it loads the + parameter tree, so the load order no longer depends on the file + system. + +On 3 October 2026 the Test job moved from macOS to `ubuntu-24.04-arm` +(commit +[`67c63861`](https://github.com/PolicyEngine/policyengine-uk/commit/67c638613)). +That change verified, on main at `3c48247e`, that: + +- the full parameter tree (2,706 parameter objects and 43,666 dated + values, including the baseline copies) is byte-identical on macOS + arm64, `ubuntu-latest` and `ubuntu-24.04-arm`; +- the YAML tests pass on both Linux runners; +- every pytest outcome matches the macOS run of the same code, test by + test. + +#1209 can be closed on that evidence. + +## Checking for a regression + +CI now runs the test suite (`.github/workflows/test-suite.yaml`: the +YAML tests and the Python tests, split over four shards) on Linux arm64 +(`ubuntu-24.04-arm`), and the install and smoke-import checks on x86 +`ubuntu-latest`. There is no macOS job. If an OS difference is +suspected again: + +1. Dump the processed parameter tree on each platform (every parameter + and every dated value, including `parameters.baseline`) and compare + checksums. This is the check the October 2026 move relied on. +2. If the trees differ, look first at anything that runs at import time + or depends on file or dictionary order. +3. If the trees match but results differ, compare the uprated dataset + variables next. #1209 showed the input data themselves were the same + on both systems. + +## References + +- Tracking issue: [#1209](https://github.com/PolicyEngine/policyengine-uk/issues/1209). +- Where the difference was first seen: [#1205](https://github.com/PolicyEngine/policyengine-uk/pull/1205). +- Diagnosis: [#1223](https://github.com/PolicyEngine/policyengine-uk/pull/1223) (closed). +- CI moved to macOS: #1092, #1207; moved back to Linux: [`67c63861`](https://github.com/PolicyEngine/policyengine-uk/commit/67c638613). +- Uprating rebuild and follow-ups: #1254, #1523, #1543, #1544. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md index 57547f47ac..cda8b82730 100644 --- a/docs/engineering/bus-fares.md +++ b/docs/engineering/bus-fares.md @@ -27,8 +27,9 @@ false and the person pays fares even if eligible for a statutory concession. For fare-paying passengers, `local_bus_single_fare_share` is the share of boardings bought as singles, bounded to [0, 1]. Its fallback is 0.70, the 2024 NTS other-local-bus estimate. -An optional `local_bus_uncapped_single_fare` overrides the representative -uncapped fare described below. Negative quantities and fares price at zero. +An optional `local_bus_uncapped_single_fare` replaces the table of uncapped +single fares described below for that person. Negative quantities and fares +price at zero. On loading a UK `Simulation`, a supplied household `bus_fare_spending` moves to `bus_fare_spending_reported`. The stored total therefore cannot freeze the @@ -66,6 +67,59 @@ residents because non-residents also travel there. Scotland and Northern Ireland use the English other-local-bus trip rate as a proxy, as Microcosm does. These translations include interchanges and differences between operator boardings and resident trips; they are not counts of legs for a known trip. +`gov.dft.bus.benchmark_year` (2024) names the fiscal year these facts describe. + +## Fares by year + +In other years each area's yield moves with its published fares index: + +``` +yield(t) = benchmark_yield * index(t) / index(benchmark_year) +``` + +`gov.dft.bus.local_bus_fares_index` holds DfT's +[BUS0415a](https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue) +quarter-end values (March 2005 = 100) for London, England outside London and +Scotland, from DfT's June 2026 release (quarters to March 2026; +[archived copy](https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods)), +the vintage Chronicle and Microcosm vendor. DfT's September 2026 release, now +served at the same address, revises the last two quarters and adds June 2026; +move to it together with Microcosm. Each value is dated at the start of the +quarter it ends, and `fiscal_year_blend` day-weights the quarters over 6 April +to 5 April, the model's year. FY2024–25 outside London is therefore +(86 × 181.6 + 92 × 185.7 + 92 × 185.9 + 90 × 206.4 + 5 × 210.5) / 365 = 190.23, +within 0.2% of the mean of its four quarter ends (189.9). After the last +published quarter the index grows with the OBR consumer price index each +January, as `private_rent_index` does. Northern Ireland has no published index, +so its yield grows with CPI from the benchmark year. Wales stays unpriced. + +The CPI projection also raises the fares in the index that the £3 cap holds at +£3, while the cap term below measures changes from a nominal £3. From January +2027 a ticket held at a cap therefore costs about £3 times the CPI growth since +March 2026 more than the cap allows. That overstates the outside-London yield +by 0.4% in 2027–28 and 0.8% in 2028–29, about 0.3 points more each year after. +A cap reform is measured against the same reference as the baseline, so its +cost does not move. + +| Area | FY2024–25 | FY2025–26 | FY2026–27 | FY2027–28 | +|---|---:|---:|---:|---:| +| London | 196.10 | 196.10 | 197.13 | 201.08 | +| England outside London | 190.23 | 214.18 | 219.09 | 223.48 | +| Scotland | 210.63 | 229.94 | 235.90 | 240.63 | + +FY2025–26 and earlier are published; later years are CPI projections. The +index records prices paid, so it already contains each cap in force while it +is published, including the rise to £3 in January 2025 (outside London 185.9 +in December 2024, 206.4 in March 2025). The cap term below adds only what the +index cannot contain. + +Each area follows its own index, so the England total moves with the +receipts-weighted mix of the London and outside-London indices: +7.6% in +FY2025–26. DfT's England index rose 6.4%. Its step from December 2024 to +March 2025 was 5.6%, against 11.0% outside London and none in London, so it +weights outside London at about one half, where receipts weight it at 61%. +A receipts target moved by the England index therefore sits below the model's +England total. ## Caps and the uncapped counterfactual @@ -79,45 +133,114 @@ representing the end of the announced scheme. These dates follow the The cap does not affect the London series, Scottish or Northern Irish fares. England outside London uses a representative participating-service assumption; route-level participation and local lower caps require caller-supplied fares. +The table of uncapped fares below is fitted to national figures and applied +the same way in every English region outside London. Where a combined authority +already runs its own lower single fare, a national cap reform therefore +overstates the savings, and elsewhere it understates them. National totals +hold, but regional and distributional splits of a cap reform lean the wrong way. -The published yield already includes ticket discounts and the benchmark cap. -For an English fare-paying person's other-local-bus boardings, the model uses: +The published yield and index already include ticket discounts and the cap in +force. `gov.dft.bus.reference_fares.cap` records that cap: the cap in force up +to the last published quarter of the index (£3 from January 2025), held after +it. For an English fare-paying person's other-local-bus boardings, the model +uses: ``` -current_yield = max(0, benchmark_yield + single_share * - (annual_min(uncapped_fare, current_cap) - - annual_min(uncapped_fare, reference_cap))) -spending = trips * boardings_per_trip * current_yield +yield(t) = max(0, benchmark_yield * index(t) / index(benchmark_year) + + single_share * sum over bands b of share_b * + (annual_min(fare_b, fares.cap, t) - + annual_min(fare_b, reference_fares.cap, t))) +spending = trips * boardings_per_trip * yield(t) ``` +Both schedules are read in the same year. While the index is published they +agree and the cap term is zero, so the baseline is the published path. After +the last published quarter the term adds the scheduled changes: the £2 cap from +January 2027 and its end in 2028. A reform of `fares.cap` adds its own +difference from the reference. Leave `reference_fares` unchanged in reforms. +When a newly published quarter is added to the index, extend +`reference_fares.cap` with the cap then in force; +`policyengine_uk/tests/test_bus_fare_parameters.py` fails until the two +schedules agree up to the last published quarter. + `annual_min` caps the fare in each date interval, then weights the intervals from 6 April to 5 April, assuming uniform journeys through the year. It does -not cap a fare using the average cap. Both `fares` and `reference_fares` preserve -calendar dates. `reference_year` is 2024 and `reference_fares` holds the benchmark -schedule fixed when the policy cap changes. The source receipts use 1 April to -31 March; this approximation treats them as the corresponding model year's -benchmark. Annual Scenario changes apply to the full model fiscal year. - -The default uncapped fare is £2.748019953976. This is an aggregate assumption, -not an observed fare distribution. The FY2024–25 cap grant is £515,693,216.60. -With `F = B - C` outside London and single share `s = 0.70`, the assumption -solves `s * (u - annual_min(u, reference_cap)) = grant / F`. Since `2 < u < 3`, -the reference year has 270 days at £2 and 95 at `u`, giving: +not cap a fare using the average cap. Both `fares` and `reference_fares` keep +calendar dates (their index files propagate `preserve_calendar_dates` to the +cap leaves), so a cap change part-way through a year applies from its date. +The source receipts use 1 April to 31 March; this approximation treats them as +the corresponding model year's benchmark. Annual Scenario changes apply to the +full model fiscal year. -``` -u = 2 + grant / (F * 0.70 * 270/365) -``` +### Uncapped single fares + +What a cap saves depends on how uncapped single fares are spread, not on their +average. `gov.dft.bus.uncapped_single_fares` holds three bands, each with its +share of single tickets and its mean uncapped fare at FY2024–25 prices: + +| Band | Share | Mean fare | +|---|---:|---:| +| `LOW`, at or below £2 | 0.55 | £1.65 | +| `MIDDLE`, above £2 and up to £3 | 0.30 | £2.21 | +| `HIGH`, above £3 | 0.15 | £3.87 | + +The fares rise with the OBR CPI after FY2024–25 and the shares stay, so a fixed +cap saves more as fares rise. The model caps each band's mean fare under both +schedules and weights the bands by their shares, which must sum to one. With +the bands at their means, the table is exact for caps of £2 and £3 at +FY2024–25 prices and approximate for other caps; a cap below the low band's +mean needs more bands. + +`scripts/fit_bus_cap_savings.py` fits the table by least squares to the facts +below, each scaled by its tolerance. It searches shares in steps of one point +and fares in steps of one penny, keeps each band's mean at least 10% above its +lower edge, and sets the low band's fare so all singles average £2.15: + +| Fact | Source | Target | Table gives | +|---|---|---:|---:| +| A full year at £2, 2023–24 | Fare cap support, DfT BUS05bi | £278.95m ± 10% | £280.9m | +| Part of the December 2024 to March 2025 index step outside London due to the £3 cap | BUS0415a: +11.0%; neighbouring quarters 0.1% to 2.3% | 10% ± 1 point of the yield | 9.8% | +| The £3 cap in 2025 | ["over £150 million"](https://www.gov.uk/government/speeches/national-bus-fare-cap) | £150m ± 20% | £140.0m | +| The £2 cap in 2027, against no cap | ["£400 million"](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027) | £400m ± 20% | £411.6m | +| Mean pre-cap price of single tickets | [DfT evaluation](https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap), Table 6 | £2.15 ± £0.10 | £2.15 | +| Share of single tickets above £2 | Table 6: £0.32 / (£2.73 − £2.00) | 0.44 ± 0.05 | 0.45 | +| Their mean pre-cap price | Table 6 | £2.73 ± £0.10 | £2.76 | +| 2024–25: £2 for 270 days, then £3 | Fare cap support, DfT BUS05bi | £515.69m, not fitted | £268.5m | + +The £400 million is read as £2 against no cap: the £3 scheme is funded only to +December 2026, and the £2 scheme replaces it. Read as £2 against a continued +£3 cap, the table gives £247.3m, 38% below. The script prints both. + +The 2024–25 grant, for the benchmark year itself, is reported and not fitted; +the table puts it at about half. Grant per fare-paying boarding rose 78% on +2023–24, while the outside-London index rose 6.7%, receipts per fare-paying +boarding rose 2.8%, and the cap rose only for the last quarter. The figure may +therefore hold more than that year's forgone fares (timing or coverage of +payments, not checked). The 2023–24 grant and the two announced figures all +put a full year at £2 near £0.3bn, not £0.6bn. The single £2.748 fare this +table replaced was calibrated to the 2024–25 grant. + +The costs use the 0.70 default single share and fare-paying boardings outside +London (BUS01 journeys less BUS01c concessionary journeys: 1,281m in 2023–24 +and 1,331m in 2024–25, which also stands for 2025 and 2027), with fares moved +to each year by CPI. Table 6 compares January to October 2022 with the same +months of 2023 and is read at face value, for the table's shape. Deflated to +2022 prices it would put the 2023–24 saving about a third above the grant. +Possible reasons, not checked, are that the grant pays operators for revenue +lost net of the journeys the cap added, and that Table 6 covers the larger +participating operators only. The two announced figures are budgets; the £2 +cap's first ten months paid £210m against £245m planned. The table is a fitted +central estimate, not an observed fare distribution. + +At FY2025–26 prices the table saves £0.39 per single ticket for a full year at +£2 against no cap, and £0.15 at £3: about £0.36bn and £0.14bn a year at +2024–25 boardings. The single £2.748 fare this replaced saved £0.75 at £2 +(about £0.70bn) and nothing at £3. -Removing the cap therefore raises the benchmark yield from about £1.55 to -£1.94 at the representative share, matching the grant-addback calculation -in issue #1871. This assumes full grant pass-through and no demand response. -Individual shares can differ, so a dataset aggregate need not recover the -published grant. Caps at or above £2.75, including the £3 cap, have no effect -relative to uncapped travel under the default £2.748 representative fare. -The default has no fare distribution above these caps, so it cannot estimate -the savings those caps give to passengers with higher fares. Supply -person-specific `local_bus_uncapped_single_fare` values above a cap to model -its effect on those passengers. Very large reductions can reach the zero-yield +A dataset or calculator can supply `local_bus_uncapped_single_fare` for each +person. Where it is supplied, that person's single tickets cost that one fare +before the cap, and the table is not used. Single-year datasets extended by +`Simulation` uprate it with CPI. Very large reductions can reach the zero-yield floor. ## Other reform controls and limits @@ -127,8 +250,8 @@ spending. Both default to one; they are neutral reform controls rather than historical index series. Keep the denominator positive. `ridership_index` defaults to one and multiplies journey-priced spending, leaving supplied journey inputs intact. The model includes no demand elasticity or automatic -ridership growth. Benchmark yields and trip translations stay nominally -constant after 2024 unless changed explicitly. Dataset CPI-uprated household +ridership growth. Trip translations stay constant after 2024 unless changed +explicitly; yields move with the fares index. Dataset CPI-uprated household bus totals do not determine prices where journeys are available. A proportional scenario can set `gov.dft.bus.fare_index` to 0.9 for a 10% diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 906d7d60d9..93f8ec037f 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -27,9 +27,11 @@ is deducted separately through `allowances`. Employee contributions represent net pay and personal contributions represent gross relief at source (RAS). Both annual-allowance income tests use [`annual_allowance_net_income`](../../policyengine_uk/utils/pensions.py). -This shared helper reconstructs the model's net-income convention from actual -income, before separately modeled pension relief, excluding the NICs-only -salary-sacrifice return. Accordingly: +This shared helper retains the model's net-income convention before separately +modeled pension relief and applies net-pay/trading-loss ordering to actual +income and member payments. The NICs-only excess never enters these amounts; +a positive supplied legacy ordinary return is actual pay/member payment. +Accordingly: - `pension_annual_allowance_adjusted_income` adds the total pension input amount to that reconstructed income, then subtracts the individual's actual @@ -69,20 +71,30 @@ prevents Step 2 relief from exceeding the remaining income. This limit applies in every model year, including before the NICs cap begins in 2029. It can therefore change pre-2029 allowances for people whose trading-loss relief is limited by income and who make relievable net-pay contributions. -Removing synthetic pay after deducting the -original relief would retain relief calculated on income the person did not -receive. Threshold income also recomputes [FA 2004 ss. 188–191 pension +The helper replaces the original loss with the loss allowed after relievable +net pay. Threshold income deducts [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. +Ordinary pension relief, the ordinary `adjusted_total_income` and both income +tests take relevant UK earnings +([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)) from +one helper, `relevant_uk_earnings`: employment income chargeable to income +tax, which is net taxable earnings ([ITEPA 2003 ss. 9(2) and +11](https://www.legislation.gov.uk/ukpga/2003/1/section/11)), so pay and +employer-paid taxable statutory pay less allowable expenses; and trading +profits after capital allowances and the trading allowance but before losses +brought forward. The pension deductions that s. 327(5) also lists are the +relief these earnings limit, so they are not taken off. + An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on -actual contributions and earnings. It does not remove returned pay or -reconstruct trading-loss relief: the supplied ANI contains neither synthetic -pay nor relief recalculated by the NICs reclassification. The annual-allowance -base is therefore independent of the NICs cap on this path too. +actual contributions and earnings. It does not remove an actual supplied +ordinary return or reconstruct trading-loss relief: the supplied ANI already +determines the income after those losses. The annual-allowance base is +independent of the NICs cap on this path too. -Other modeled deductions do not depend on the returned pay: brought-forward +Other modeled deductions retain their existing treatment: brought-forward trade losses, capital allowances and the trading allowance depend on trading profits. Property finance-cost relief is a downstream tax reducer. Gift Aid's adjusted-net-income deduction and rate-limit extension do not reduce either @@ -116,35 +128,107 @@ for the whole year according to age on that date; payments are not split at the birthday. Contributions before 75 that exceed the earnings relief limit still count. Adjusted income subtracts all actual member contributions under s. 228ZA(4)(d)(ii), including those excluded from the post-75 DC input. -For defined-benefit -arrangements, supply the statutory total pension input amount, including -valued DB accrual, through this variable. Cash employer funding is not a -valuation of DB rights. Survey estimates retain the existing cash proxy; -this correction does not reconstruct DB rights, overseas adjustments, unused -allowance histories or anti-avoidance arrangements under s. 228ZB. +Defined-benefit +arrangements are counted separately; see the last section. Supplying +`pension_contributions_for_annual_allowance` itself overrides the whole total. +The model does not reconstruct overseas adjustments or anti-avoidance +arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) -changes NICs treatment without changing s. 228ZA. From 2029, the model moves -sacrifice above the NICs exemption cap into -employee contributions and employment income. The AA calculations remove -that synthetic return from both pay and member contributions and recompute -income-dependent relief as described above. Pension relief preserves the +changes NICs treatment without changing s. 228ZA. From 2029, the model includes +sacrifice above the NICs exemption cap only in Class 1 earnings through +`salary_sacrifice_pension_ni_excess`. It does not create ordinary pay or member +contributions. The legacy `salary_sacrifice_returned_to_income` defaults to +zero; a positive explicit input represents actual ordinary pay/member payment +and is retained in the AA calculations. Pension relief preserves the basic RAS amount even when cash earnings are below £3,600 and gives no member relief from age 75. The DC input keeps the full employer sacrifice at every age. The threshold-income add-back uses the full -sacrifice before this reclassification. The separate employer-cost wage +sacrifice before the NICs cap. The separate employer-cost wage response can still change income; it is held fixed when testing the cap's statutory effect. -Cap invariance covers the two taper incomes, pension input and allowance. -Charge comparisons additionally hold ordinary income-tax inputs fixed: the -separate ANI and personal-allowance interaction can change the marginal tax -charged on the same excess. Ordinary income tax retains its existing -reclassification behavior; [#2454](https://github.com/PolicyEngine/policyengine-uk/issues/2454) -tracks that separate defect. +With the separate wage response held fixed, the cap does not change ordinary +pay, member payments, ANI or the personal allowance. Cap-invariance checks +cover those paths alongside the two taper incomes, pension input, allowance +and charge. Actual NI liability changes through the existing Class 1 curves. The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation conventions. Historical years use calendar-year proxies for the named tax years; the 2015 transitional pension-input alignment rules are outside this taper calculation. + +## Survey data: defined benefits and carry forward + +Surveys record contributions paid this year. Two statutory amounts need more: +the pension input amount of a defined-benefit arrangement, and unused +allowance carried forward from earlier years. + +**Defined benefits.** A defined-benefit arrangement's pension input amount is +the increase in the value of the member's rights over the year +([s. 234](https://www.legislation.gov.uk/ukpga/2004/12/section/234)): 16 times +the annual pension, plus any separate lump sum, at the end less the same at the +start, with the start raised by CPI +([s. 235](https://www.legislation.gov.uk/ukpga/2004/12/section/235)). It is not +the cash paid in. + +- `defined_benefit_pension_share` (an input from 0 to 1, default nil) is the + share of the person's workplace contributions (employer, net pay and salary + sacrifice) paid into defined-benefit arrangements. Personal (relief at + source) contributions are money purchase. +- `defined_benefit_pension_input_amount` replaces those contributions in + `pension_contributions_for_annual_allowance`. Supply it where it is known, + as on a pension savings statement, with the share of the contributions that + fund it: contributions outside the share also count as money purchase + input. Otherwise it is the contributions times + `gov.simulation.pensions.defined_benefit_input_per_contribution`, which is + 1. In the four largest public service schemes in 2024-25 the input of an + active member with ten years' service is 0.8 to 1.2 times employer and + member contributions together (the parameter file gives the figures), and + survey employer contributions drawn from regular contribution rates leave + out deficit repair payments. +- With the default share of nil, every contribution is counted as money + purchase input, as before. With the ratio at 1, a supplied share alone + changes the input only for net-pay contributions to a defined-benefit + scheme from age 75, which now count, since the defined-benefit input has + no age limit. Results move only once data supplies a valued input amount. + +**Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) +adds the allowance left unused in each of the three previous tax years in +which the person was a scheme member, earliest first. `unused_pension_annual_allowance` +can be supplied for a year. A supplied or stored value applies to that year +only: the window of years moves on, so dataset projection does not copy it +into later years (`YEAR_SPECIFIC_INPUTS` in `data/economic_assumptions.py`), +which use the estimate. From 2020-21 it otherwise defaults to an estimate: + +- In each of the five previous years, the pension input amount was this + year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the + incomes tested for that year's taper were this year's, each deflated by + average earnings growth. +- Each year's allowance follows that year's parameters, tapered on those + incomes. Each year's excess uses the unused allowance of the three years + before it, earliest first (s. 228A(7)), and the unused allowance of the + three years before this one is carried forward. +- A person with pension input this year is taken to have been a scheme member + in each of those years; with none, nothing is carried forward. + +With a ratio of 1 the estimate matters mainly where the allowance has risen, +as it did to £60,000 in 2023-24, or where pay has grown. A ratio of 0, no input +in earlier years, gives the most there could be. The ratio of 1 is a +convention, since HMRC publishes no statistics on carry forward. Real +excesses are often lumpy (a defined-benefit promotion, a one-off +contribution), so a steady history probably understates carry forward. + +**Against HMRC.** HMRC's Private pension statistics (July 2026) count 30,440 +individuals reporting contributions above their allowance through Self +Assessment in 2024-25 (provisional, Table 7), after carry forward, and +estimate annual allowance charges of £0.3bn (Table 6). On the certified +Enhanced FRS the model's 2024 count and charge are several times these, +because the dataset imputes employer contributions as three times employee +contributions ([policyengine-uk-data#606](https://github.com/PolicyEngine/policyengine-uk-data/issues/606)). +With employer contributions at a third of their stored values, the count, +the charge and the chargeable amount (against Table 7's £672m of excess) all +fall within half to one and a half times HMRC's figures. +`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks all three +against that band, as a strict expected failure until the data is fixed. diff --git a/docs/engineering/salary-sacrifice-nics.md b/docs/engineering/salary-sacrifice-nics.md new file mode 100644 index 0000000000..86da793f53 --- /dev/null +++ b/docs/engineering/salary-sacrifice-nics.md @@ -0,0 +1,31 @@ +# Salary sacrifice and the NICs cap + +`employment_income` supplies cash pay after contractual salary sacrifice, and +`pension_contributions_via_salary_sacrifice` supplies the employer contribution +separately. UKSimulation preserves this existing input convention; the model +does not deduct that sacrifice a second time from cash pay. + +The cap limits NIC exemption, rather than returning ordinary pay or creating a +member pension payment. `salary_sacrifice_pension_ni_excess` adds the excess once +to `ni_class_1_income`. Full actual sacrifice still counts once in the current +DC pension-input proxy. The capped `..._adjusted` measure is the NIC-exempt +amount. The two NI reporting measures remain approximations; actual liability +uses the Class 1 curves and age flags. + +The legacy `salary_sacrifice_returned_to_income` defaults to zero. Explicit +ordinary-return inputs retain their previous pay/member-payment semantics. +Actual reported member payments and the separate broad-base haircut assumption +are unchanged. The merged #2441 age-75 member accounting and annual-allowance +taper rules are retained: member payments before age 75, employer payments and +full sacrifice enter the default DC input, while member payments from age 75 +are excluded. A positive supplied ordinary return is actual pay and a member +payment in these readers too; it is not removed as an unreceived NICs return. +The AA helper still applies the net-pay/trading-loss ordering, RAS earnings and +age limits, supplied-ANI restoration and statutory pension-input overrides. + +The [NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) +provides the contributions-law machinery for 2029–30 onwards. The existing +2029-04-06 parameter and annual sampling are retained. This is not a complete +implementation of future payroll-interval regulations. Employer contributions +remain subject to [ITEPA 2003 s308](https://www.legislation.gov.uk/ukpga/2003/1/section/308); +member relief separately follows [FA 2004 ss188–191](https://www.legislation.gov.uk/ukpga/2004/12/section/188). diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index b506b44125..9e4df81565 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -86,10 +86,10 @@ the same suite, tree; a release tests the wheel it is about to publish, installed on its own. The suite runs the YAML policy tests in one job and splits the Python tests -over four runners. Each runner runs one shard: +over five runners. Each runner runs one shard: ```bash -pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/4 -o xfail_strict=true +pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/5 -o xfail_strict=true ``` `--shard K/N` is defined in `policyengine_uk/tests/conftest.py`. It keeps the diff --git a/myst.yml b/myst.yml index 54e94000bb..5d58330cbd 100644 --- a/myst.yml +++ b/myst.yml @@ -37,6 +37,7 @@ project: - pattern: docs/book/policy/*.md - title: Programmes children: + - file: docs/book/programs/contrib/ubi-center/carbon-tax.ipynb - pattern: docs/book/programs/gov/*/*.md site: options: diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index ce9419b944..a59ebd9920 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -56,6 +56,28 @@ "UNKNOWN": "england", } +# The growth series (under gov.economic_assumptions.yoy_growth) of each Region +# label's water and sewerage charges (#2501). Ofwat's series is the average +# household bill in England and Wales. Scottish Water's charges follow limits +# set by the Water Industry Commission for Scotland. Northern Ireland has no +# domestic water charges, so any a dataset gives its households are held flat. +# Past PROJECTION_END_YEAR, policyengine-core grows the column by the index +# its variable declares (Ofwat's) in every country. +# An unknown region takes the England and Wales series, which covers most +# households in Great Britain. +WATER_BILLS_GROWTH = "gov.economic_assumptions.yoy_growth" +_WATER_GROWTH_SERIES = { + **dict.fromkeys(_ENGLAND_REGIONS, "ofwat.water_bills"), + "WALES": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "NORTHERN_IRELAND": None, + "UNKNOWN": "ofwat.water_bills", +} +# Inputs uprated by country in this module rather than through +# uprating_indices.yaml, so the index their variable declares must not grow +# them a second time after LEGACY_PROJECTION_END_YEAR. +COUNTRY_UPRATED_INPUTS = frozenset({"water_and_sewerage_charges"}) + # The last year a single-year dataset is projected to: 2032, fiscal year # 2032-33. An OBR forecast runs five years past the fiscal year it is made in, @@ -90,6 +112,11 @@ "gas_consumption", } ) +# Inputs that describe only the year they are stored for, so are not carried +# into projected years. Unused annual allowance comes from the three tax years +# before this one (Finance Act 2004 s. 228A), a window that moves each year; a +# projected year uses the variable's own estimate instead (#2523). +YEAR_SPECIFIC_INPUTS = frozenset({"unused_pension_annual_allowance"}) def _unmapped_uprating_indices(uprating): @@ -102,6 +129,7 @@ def _unmapped_uprating_indices(uprating): if variable.uprating is not None and name not in mapped and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } @@ -133,9 +161,11 @@ def uprating_input_paths() -> list: """ uprating = _uprating_index_map() series = {value for value in _COUNCIL_TAX_GROWTH_SERIES.values() if value} + water_series = {value for value in _WATER_GROWTH_SERIES.values() if value} return sorted( set(uprating) | {f"{COUNCIL_TAX_GROWTH}.{name}" for name in series} + | {f"{WATER_BILLS_GROWTH}.{name}" for name in water_series} | { SOCIAL_RENT_GROWTH, PRIVATE_RENT_GROWTH, @@ -269,6 +299,13 @@ def apply_single_year_uprating( f"not {source_year}–{target_year}." ) + # Year-specific inputs are dropped from every projected year. + for table_name in current_year.table_names: + table = getattr(current_year, table_name) + stored = [name for name in YEAR_SPECIFIC_INPUTS if name in table.columns] + if stored: + table.drop(columns=stored, inplace=True) + # First, apply standard variable-YoY growth based uprating. uprating = _uprating_index_map() @@ -290,6 +327,12 @@ def apply_single_year_uprating( current_year = uprate_council_tax(current_year, previous_year, parameters) + # Water and sewerage charges are uprated by country. + + current_year = uprate_water_and_sewerage_charges( + current_year, previous_year, parameters + ) + # Rent is uprated by OBR forecasts/outturns by region. current_year = uprate_rent(current_year, previous_year, parameters) @@ -393,6 +436,40 @@ def uprate_council_tax( return current_year +def uprate_water_and_sewerage_charges( + current_year: UKSingleYearDataset, + previous_year: UKSingleYearDataset, + parameters: ParameterNode, +): + # Uprate water and sewerage charges for a single year dataset, by country. + + if "water_and_sewerage_charges" not in previous_year.household.columns: + return current_year + region = pd.Series(np.asarray(current_year.household["region"]).astype(str)) + unrecognised = set(region) - set(_WATER_GROWTH_SERIES) + if unrecognised: + raise ValueError( + "Cannot uprate water and sewerage charges for unrecognised regions: " + f"{sorted(unrecognised)}" + ) + growth_by_region = {} + for name, series in _WATER_GROWTH_SERIES.items(): + rate = ( + None + if series is None + else parameters.get_child(f"{WATER_BILLS_GROWTH}.{series}")( + current_year.time_period + ) + ) + growth_by_region[name] = 0.0 if rate is None else rate + growth_rates = region.map(growth_by_region).to_numpy() + + current_year.household["water_and_sewerage_charges"] = previous_year.household[ + "water_and_sewerage_charges" + ] * (1 + growth_rates) + return current_year + + def uprate_rent( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, @@ -587,6 +664,7 @@ def reset_growthfactor_uprating( variable for variables in uprating.values() for variable in variables } growthfactor_uprated_variables.update({"council_tax", "rent"}) + growthfactor_uprated_variables.update(COUNTRY_UPRATED_INPUTS) growthfactor_uprated_variables.update(_unmapped_uprating_indices(uprating)) first_year = min(dataset.datasets.keys()) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index cb5d415d0d..169920271d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -481,7 +481,7 @@ exclusions: - {variable: ni_class_3, kind: component, head: nics_individual} - {variable: ni_class_4, kind: component, head: nics_individual} - {variable: ni_class_1_employer, kind: component, head: nics_employer} - - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to employment_income, so Class 1 already charges it."} + - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to ni_class_1_income, so Class 1 already charges it."} - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} - {variable: tax, kind: composite, spans: [income_tax, nics_individual]} - {variable: benunit_tax, kind: composite, spans: [income_tax, nics_individual]} diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ed2d334e12..e82333b1a6 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -28,10 +28,16 @@ gov.economic_assumptions.yoy_growth.finance_ni.domestic_rates: - domestic_rates gov.economic_assumptions.yoy_growth.obr.average_earnings: +# A formula of defined_benefit_pension_contributions; listed so a dataset that +# stores a valued defined-benefit input amount grows it with pay. +- defined_benefit_pension_input_amount - employee_pension_contributions - employer_pension_contributions - employment_income - employment_income_before_lsr +- pension_contributions_via_salary_sacrifice +# The pre-9 July 2015 share of the line above (FA 2004 s. 228ZA(6)). +- pension_contributions_via_salary_sacrifice_pre_2015 - personal_pension_contributions - student_loan_repayments gov.economic_assumptions.yoy_growth.obr.consumer_price_index: @@ -65,6 +71,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - income_support_reported - jsa_contrib_reported - jsa_income_reported +- local_bus_uncapped_single_fare - maintenance_expenses - maternity_allowance_reported - miscellaneous_consumption @@ -132,13 +139,26 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - savings - savings_interest_income - sublet_income +- cash_isa +- consumer_debt +- mortgage_debt +- rent_paid_as_boarder +- rent_paid_as_lodger +- stocks_and_shares_isa +- owned_company_capital +- owned_company_holding_value +- owned_company_trade_assets # An explicit complete UC override stored in a dataset is uprated like the # financial input; its default no longer derives from that input. - uc_reported_capital gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income +- self_employment_gross_receipts - trading_loss +- owned_company_income_share gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income +gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: +- housing_service_charges gov.economic_assumptions.yoy_growth.ons.population: - household_weight diff --git a/policyengine_uk/dynamics/participation.py b/policyengine_uk/dynamics/participation.py index 4ec3451d68..7b058f621f 100644 --- a/policyengine_uk/dynamics/participation.py +++ b/policyengine_uk/dynamics/participation.py @@ -22,6 +22,7 @@ def calculate_participation_elasticities( sim: Simulation, earnings_quintile: np.ndarray, + year: int = None, ) -> np.ndarray: """Calculate labour force participation elasticities by demographic group. @@ -31,24 +32,25 @@ def calculate_participation_elasticities( Args: sim: PolicyEngine simulation object earnings_quintile: Array indicating earnings quintile (1-5) for each person + year: Requested year, or the simulation default if omitted Returns: Array of participation elasticities for each person """ # Get demographic characteristics - gender = sim.calculate("gender") + gender = sim.calculate("gender", year) # OBR Tables A1/A2 include cohabiting couples; legal marriage is separate. - is_couple = sim.calculate("is_couple", map_to="person") - composition = benunit_age_18_composition(sim) + is_couple = sim.calculate("is_couple", year, map_to="person") + composition = benunit_age_18_composition(sim, year) has_children = composition["count_under_18"].values > 0 youngest_child_age = composition["youngest_under_18_age"].values is_single = ~is_couple # Get partner employment status for individuals in couples benunit_count_adults = composition["count_aged_18_or_over"].values - employment_income = sim.calculate("employment_income") - benunit_id = sim.calculate("benunit_id", map_to="person") - adult_index = sim.calculate("adult_index") + employment_income = sim.calculate("employment_income", year) + benunit_id = sim.calculate("benunit_id", year, map_to="person") + adult_index = sim.calculate("adult_index", year) # Create DataFrame for efficient partner employment calculation df = pd.DataFrame( @@ -338,7 +340,7 @@ def calculate_gain_to_work( employment_income = sim.calculate("employment_income", year) hours_worked = sim.calculate("hours_worked", year) household_net_income = sim.calculate("household_net_income", year, map_to="person") - adult_index = sim.calculate("adult_index") + adult_index = sim.calculate("adult_index", year) # Impute wages for non-workers if requested if impute_nonworker_wages: @@ -580,8 +582,8 @@ def apply_participation_responses( if sim.baseline is None: return pd.DataFrame() - # Set random seed for reproducibility - np.random.seed(random_seed) + # Preserve the legacy MT19937 stream without mutating process-global state. + rng = np.random.RandomState(random_seed) # Calculate excluded individuals from .labour_supply import calculate_excluded_from_labour_supply_responses @@ -590,7 +592,7 @@ def apply_participation_responses( # Get employment status employment_income = sim.calculate("employment_income", year) - adult_index = sim.calculate("adult_index") + adult_index = sim.calculate("adult_index", year) eligible = ~excluded & (adult_index > 0) & (adult_index <= count_adults) currently_working = (employment_income > 0) & eligible currently_not_working = (employment_income == 0) & eligible @@ -627,7 +629,7 @@ def apply_participation_responses( sim, year, hours_for_new_entrants, random_seed ) elasticities_wrt_income = calculate_participation_elasticities( - sim, earnings_quintile + sim, earnings_quintile, year ) # Transform elasticities from w.r.t. in-work income to w.r.t. gain-to-work @@ -656,11 +658,17 @@ def apply_participation_responses( # When workers have worse incentives (negative GTW change), this creates a "deficit" # that pushes non-workers further away from employment - # Group people by elasticity group for surplus calculation - elasticity_groups = pd.cut(elasticities, bins=20, labels=False, duplicates="drop") + # Excluded records must not change the bin bounds for response candidates. + # Twenty equal-width bins remain an inherited spillover approximation. + elasticity_groups = np.full(elasticities.shape, np.nan) + if eligible.any(): + elasticity_groups[eligible] = pd.cut( + elasticities[eligible], bins=20, labels=False, duplicates="drop" + ) surplus_participation_change = np.zeros_like(direct_participation_change) + # NaN groups are safe: ineligible rows are in neither response-candidate mask. for group in np.unique(elasticity_groups[~np.isnan(elasticity_groups)]): group_mask = elasticity_groups == group workers_in_group = group_mask & currently_working @@ -694,29 +702,29 @@ def apply_participation_responses( new_employment_income = employment_income.copy() participation_response = np.zeros_like(employment_income, dtype=bool) - # For currently working individuals: chance of leaving work - for i in np.where(currently_working)[0]: - # Negative participation change means lower probability of working - exit_probability = max( - 0, -participation_change[i] - ) # Only consider negative changes - if np.random.random() < exit_probability: - new_employment_income[i] = 0 - participation_response[i] = True # Exited work - - # For currently non-working individuals: chance of entering work + # Draw for every worker first, then every non-worker, in ascending row order. + # Keep zero-probability and nonpositive-wage candidates in the stream. + workers = np.flatnonzero(currently_working) + exited = workers[ + rng.random_sample(len(workers)) < np.maximum(0, -participation_change[workers]) + ] + new_employment_income[exited] = 0 + participation_response[exited] = True + imputed_wages = impute_wages_for_nonworkers(sim, year, hours_for_new_entrants) - for i in np.where(currently_not_working)[0]: - # Positive participation change means higher probability of working - entry_probability = max( - 0, participation_change[i] - ) # Only consider positive changes - if np.random.random() < entry_probability and imputed_wages[i] > 0: - # Cast for the same reason as the in-work assignment above. - new_employment_income[i] = new_employment_income.dtype.type( - imputed_wages[i] - ) - participation_response[i] = True # Entered work + nonworkers = np.flatnonzero(currently_not_working) + entered = nonworkers[ + ( + rng.random_sample(len(nonworkers)) + < np.maximum(0, participation_change[nonworkers]) + ) + & (imputed_wages[nonworkers] > 0) + ] + # Match the legacy scalar assignment's model-storage precision. + new_employment_income[entered] = imputed_wages[entered].astype( + new_employment_income.dtype + ) + participation_response[entered] = True # Update simulation with new employment incomes sim.set_input("employment_income", year, new_employment_income) diff --git a/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml b/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml new file mode 100644 index 0000000000..6fcb91d966 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/freeze.yaml @@ -0,0 +1,37 @@ +description: >- + While this parameter is true, Northern Ireland LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. It works as gov.dwp.LHA.freeze does for Great Britain, but follows the Northern Ireland Housing Executive's determinations under Northern Ireland regulations. Northern Ireland has so far made a matching regulation for each Great Britain Modification Order, so the values match the Great Britain parameter. A year here is the fiscal year from April. Values from 2027 onward reflect announced rather than enacted policy. +values: + 2015-01-01: false + 2020-04-06: + value: false + metadata: + reference: + - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 + href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made + 2021-04-06: + value: true + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2021 + href: https://www.legislation.gov.uk/nisr/2021/14/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment and Modification) Regulations (Northern Ireland) 2022 + href: https://www.legislation.gov.uk/nisr/2022/15/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2023 + href: https://www.legislation.gov.uk/nisr/2023/4/made + 2024-04-06: + value: false + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024 + href: https://www.legislation.gov.uk/nisr/2024/3/made + 2025-04-06: + value: true + metadata: + reference: + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2025 + href: https://www.legislation.gov.uk/nisr/2025/1/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2026 + href: https://www.legislation.gov.uk/nisr/2026/2/made +metadata: + unit: bool + label: Northern Ireland LHA freeze diff --git a/policyengine_uk/parameters/gov/dfc/LHA/index.yaml b/policyengine_uk/parameters/gov/dfc/LHA/index.yaml new file mode 100644 index 0000000000..9afca181b9 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/index.yaml @@ -0,0 +1,3 @@ +description: Local Housing Allowance rules for Northern Ireland, where the Northern Ireland Housing Executive determines the rates under regulations made by the Department for Communities. The Rent Officers Orders behind gov.dwp.LHA cover Great Britain only. +metadata: + label: Northern Ireland LHA diff --git a/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml b/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml new file mode 100644 index 0000000000..1ffd6c79d7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/LHA/percentile.yaml @@ -0,0 +1,10 @@ +description: Northern Ireland Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. It works as gov.dwp.LHA.percentile does for Great Britain. Northern Ireland's lists of rents in the model are copies of English ones, so another value scales the published 30th percentile by the median English ratio for the category. This parameter does not apply if Northern Ireland LHA is frozen, or to determinations before April 2020, which are taken as published. +values: + 2015-01-01: + value: 0.3 + reference: + - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 (April 2020 reset to the 30th percentile) + href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made +metadata: + unit: /1 + label: Northern Ireland LHA percentile diff --git a/policyengine_uk/parameters/gov/dfc/index.yaml b/policyengine_uk/parameters/gov/dfc/index.yaml new file mode 100644 index 0000000000..a6b03ecf30 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfc/index.yaml @@ -0,0 +1,3 @@ +metadata: + label: DfC + description: Department for Communities (Northern Ireland) diff --git a/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml new file mode 100644 index 0000000000..5139c967dc --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/benchmark_year.yaml @@ -0,0 +1,15 @@ +description: Fiscal year of the published bus fare benchmark. yield_per_boarding and + boardings_per_trip describe this year (FY2024-25 receipts and boardings), and the yield in + another model year is the benchmark yield times the local bus fares index in that year over + its value in this one. +values: + '0001-01-01': 2024 +metadata: + unit: year + period: year + label: bus fare benchmark year + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml index 35741aa6db..dd8d8e4786 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml @@ -1,5 +1,6 @@ description: Proportional multiplier on bus fares; a neutral reform lever, not a measured - historical fare index. + historical fare index. The published DfT fares index that moves the baseline is + gov.dft.bus.local_bus_fares_index. values: '0001-01-01': 1 metadata: diff --git a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml new file mode 100644 index 0000000000..5eb5a3b58c --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/local_bus_fares_index.yaml @@ -0,0 +1,65 @@ +description: DfT local bus fares index (table BUS0415a, March 2005 = 100) for each area + whose bus fares the model prices from journeys, from DfT's June 2026 release (quarters to + March 2026). Each value is the quarter-end figure DfT publishes, dated at the start of the + quarter it ends. fiscal_year_blend day-weights the quarters over each 6 April to 5 April + model year. After the last published quarter the index grows with the OBR consumer price + index. The yield per fare-paying boarding in a model year is the benchmark yield times this + index in that year over its value in the benchmark year. Northern Ireland has no published + index. +metadata: + label: local bus fares index + propagate_metadata_to_children: true + fiscal_year_blend: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + # The June 2026 file (sha256 04721e9ade3b39c415ce2a0bcc045ec6d4ccb8afb6e33d525f619cf452bc7adf, + # 23,434 bytes) is the vintage Chronicle and Microcosm vendor. Its DfT address now serves + # the September 2026 release, which revises the December 2025 and March 2026 quarters and + # adds June 2026; move to it together with Microcosm. The archived copy does not change. + reference: + - title: DfT bus statistics, table BUS0415a, local bus fares index by metropolitan area status + and country, June 2026 release (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT bus statistics data tables, BUS04 costs, fares and revenue (current release) + href: https://www.gov.uk/government/statistical-data-sets/bus04-costs-fares-and-revenue +LONDON: + 2023-01-01: 194.3 # quarter ending March 2023 + 2023-04-01: 194.3 # quarter ending June 2023 + 2023-07-01: 194.3 # quarter ending September 2023 + 2023-10-01: 194.3 # quarter ending December 2023 + 2024-01-01: 196.1 # quarter ending March 2024 + 2024-04-01: 196.1 # quarter ending June 2024 + 2024-07-01: 196.1 # quarter ending September 2024 + 2024-10-01: 196.1 # quarter ending December 2024 + 2025-01-01: 196.1 # quarter ending March 2025 + 2025-04-01: 196.1 # quarter ending June 2025 + 2025-07-01: 196.1 # quarter ending September 2025 + 2025-10-01: 196.1 # quarter ending December 2025 + 2026-01-01: 196.10649454164795 # quarter ending March 2026, unrounded in the DfT file +ENGLAND_OUTSIDE_LONDON: + 2023-01-01: 173.1 # quarter ending March 2023 + 2023-04-01: 175.6 # quarter ending June 2023 + 2023-07-01: 177.8 # quarter ending September 2023 + 2023-10-01: 178.8 # quarter ending December 2023 + 2024-01-01: 179.9 # quarter ending March 2024 + 2024-04-01: 181.6 # quarter ending June 2024 + 2024-07-01: 185.7 # quarter ending September 2024 + 2024-10-01: 185.9 # quarter ending December 2024 + 2025-01-01: 206.4 # quarter ending March 2025 + 2025-04-01: 210.5 # quarter ending June 2025 + 2025-07-01: 213.6 # quarter ending September 2025 + 2025-10-01: 214.3 # quarter ending December 2025 + 2026-01-01: 217.95569364121366 # quarter ending March 2026, unrounded in the DfT file +SCOTLAND: + 2023-01-01: 185.2 # quarter ending March 2023 + 2023-04-01: 203.1 # quarter ending June 2023 + 2023-07-01: 203.1 # quarter ending September 2023 + 2023-10-01: 203.1 # quarter ending December 2023 + 2024-01-01: 203.3 # quarter ending March 2024 + 2024-04-01: 207.7 # quarter ending June 2024 + 2024-07-01: 210.9 # quarter ending September 2024 + 2024-10-01: 211.3 # quarter ending December 2024 + 2025-01-01: 211.5 # quarter ending March 2025 + 2025-04-01: 228 # quarter ending June 2025 + 2025-07-01: 228.4 # quarter ending September 2025 + 2025-10-01: 228.4 # quarter ending December 2025 + 2026-01-01: 234.6832699716523 # quarter ending March 2026, unrounded in the DfT file diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml index 79092767e7..7ff2c77e18 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml @@ -1,18 +1,20 @@ -description: Fixed national cap schedule for the bus-fare yield benchmark. Read in reference_year; leave unchanged when reforming fares.cap. +description: National single bus fare cap that the published local bus fares index already + reflects. It follows gov.dft.bus.fares.cap up to the last published quarter of + gov.dft.bus.local_bus_fares_index and holds the cap then in force after it. The model adds + the effect of any difference between the two schedules, so the scheduled 2027 GBP 2 cap and + its end in 2028 enter the baseline, and a reform of fares.cap adds its own effect. Leave + this schedule unchanged in reforms; extend it when a newly published quarter is added to + the index. values: 0001-01-01: .inf 2023-01-01: 2 2025-01-01: 3 - 2027-01-01: 2 - 2028-01-01: .inf metadata: unit: currency-GBP period: year - label: benchmark single bus fare cap + label: single bus fare cap in the published fares index reference: - title: DfT evaluation of the two-pound bus fare cap, 2023 to 2024 href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap - title: DfT three-pound national bus fare cap, through December 2026 href: https://www.gov.uk/guidance/3-national-bus-fare-cap - - title: DfT two-pound bus fares from January to December 2027 - href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml index 43cc0310fe..0fbfa3545f 100644 --- a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml @@ -1,2 +1,6 @@ metadata: + # The formula day-weights the cap over 6 April to 5 April itself, so the + # leaves keep their calendar dates instead of fiscal-year annualisation. + # Leaves only see node metadata that is propagated. preserve_calendar_dates: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml deleted file mode 100644 index c166df37d5..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Fiscal year of the published bus-fare yield benchmark, used only to price - the reference single-fare cap. -values: - '0001-01-01': 2024 -metadata: - unit: year - period: year - label: reference year - reference: - - title: DfT bus statistics data tables, BUS01 and BUS05i - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, NTS and published-receipts pricing evidence - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml deleted file mode 100644 index 1730a733c3..0000000000 --- a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: Representative uncapped single fare inferred from the 2024-25 fare-cap grant, - fare-paying boardings and a 70 percent single-ticket share. Assumes no demand response - and full grant pass-through. This is a modelling assumption, not an observed individual - fare. Caps at or above GBP 2.75, including the GBP 3 cap, have no effect relative to - uncapped travel with this default GBP 2.748 fare. Supply person-specific - local_bus_uncapped_single_fare inputs to represent fares above those caps. - See docs/engineering/bus-fares.md. -values: - '2024-01-01': 2.748019953976 -metadata: - unit: currency-GBP - period: year - label: assumed uncapped single bus fare - reference: - - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 - href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables - - title: Microcosm 954, published-fact extraction and pricing derivation - href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml new file mode 100644 index 0000000000..9b87a44996 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/fare.yaml @@ -0,0 +1,29 @@ +description: Mean uncapped single bus fare in England outside London in each band, at + FY2024-25 prices and uprated with the OBR consumer price index after that, so a fixed cap + saves more as fares rise. +metadata: + unit: currency-GBP + period: year + label: mean uncapped single bus fare by band + propagate_metadata_to_children: true + uprating: gov.economic_assumptions.indices.obr.consumer_price_index + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 1.65 +MIDDLE: + '2024-01-01': 2.21 +HIGH: + '2024-01-01': 3.87 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml new file mode 100644 index 0000000000..b1efbc0cdf --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/index.yaml @@ -0,0 +1,9 @@ +description: Uncapped single bus fares in England outside London in three bands, each with + its share of single tickets and its mean fare. The model prices a change in the national + single fare cap as the change in the mean capped fare over the bands, for the single-ticket + share of fare-paying boardings. The table is fitted to DfT's evaluation of the GBP 2 cap, + the 2023-24 fare cap grant, the January 2025 fares index step and the funding announced for + the GBP 3 cap in 2025 and the GBP 2 cap in 2027. See docs/engineering/bus-fares.md and + scripts/fit_bus_cap_savings.py. +metadata: + label: uncapped single bus fares diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml new file mode 100644 index 0000000000..20cdc48e8c --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fares/share.yaml @@ -0,0 +1,28 @@ +description: Share of single bus tickets in England outside London in each band of uncapped + fare at FY2024-25 prices. LOW is at or below GBP 2, MIDDLE above GBP 2 and up to GBP 3, and + HIGH above GBP 3. The shares must sum to one. +metadata: + unit: /1 + period: year + label: share of single tickets by uncapped fare band + propagate_metadata_to_children: true + reference: + - title: Frontier Economics and SYSTRA for DfT, evaluation of the first 10 months of the GBP 2 + bus fare cap, Table 6 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT bus statistics, BUS01 and BUS05i (fare cap support), 2023-24 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT bus statistics, table BUS0415a, local bus fares index, June 2026 release + (archived copy) + href: https://web.archive.org/web/20260619124752/https://assets.publishing.service.gov.uk/media/6a310ac715f2a70fac7e6006/bus0415.ods + - title: DfT written statement, national bus fare cap (over GBP 150 million for the GBP 3 cap + in 2025), 29 October 2024 + href: https://www.gov.uk/government/speeches/national-bus-fare-cap + - title: DfT, GBP 2 bus fares from January 2027 (GBP 400 million), 22 July 2026 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 +LOW: + '2024-01-01': 0.55 +MIDDLE: + '2024-01-01': 0.3 +HIGH: + '2024-01-01': 0.15 diff --git a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml index 774aeeb681..5f847060a6 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml @@ -1,5 +1,5 @@ description: >- - While this parameter is true, LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. When it is false, rates are determined from that year's percentile rents (the 30th percentile by default), the national maximum, the anomalous-rate rule and, from April 2024, the March 2020 minimum. A year here is the fiscal year from April, so the value from 6 April 2025 governs the rates in force from April 2025. Determinations before April 2020 are taken as published, since they followed CPI and 1% uprating and then the 2016 to 2020 freeze with targeted affordability uplifts, so this parameter governs determinations from April 2020 onward. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. + While this parameter is true, Great Britain LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. When it is false, rates are determined from that year's percentile rents (the 30th percentile by default), the national maximum, the anomalous-rate rule and, from April 2024, the March 2020 minimum. A year here is the fiscal year from April, so the value from 6 April 2025 governs the rates in force from April 2025. Determinations before April 2020 are taken as published, since they followed CPI and 1% uprating and then the 2016 to 2020 freeze with targeted affordability uplifts, so this parameter governs determinations from April 2020 onward. The Rent Officers Orders apply in Great Britain only; Northern Ireland rates follow gov.dfc.LHA.freeze. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. values: 2015-01-01: false 2020-04-06: @@ -8,8 +8,6 @@ values: reference: - title: The Social Security (Coronavirus) (Further Measures) Regulations 2020, reg. 4 (April 2020 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2020/371/regulation/4/made - - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 - href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made 2021-04-06: value: true metadata: @@ -20,20 +18,12 @@ values: href: https://www.legislation.gov.uk/uksi/2021/1380/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2023 (determinations in 2023) href: https://www.legislation.gov.uk/uksi/2023/6/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2021 - href: https://www.legislation.gov.uk/nisr/2021/14/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment and Modification) Regulations (Northern Ireland) 2022 - href: https://www.legislation.gov.uk/nisr/2022/15/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2023 - href: https://www.legislation.gov.uk/nisr/2023/4/made 2024-04-06: value: false metadata: reference: - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Amendment) Order 2024 (April 2024 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2024/11/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024 - href: https://www.legislation.gov.uk/nisr/2024/3/made 2025-04-06: value: true metadata: @@ -42,10 +32,6 @@ values: href: https://www.legislation.gov.uk/uksi/2025/5/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2026 href: https://www.legislation.gov.uk/uksi/2026/5/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2025 - href: https://www.legislation.gov.uk/nisr/2025/1/made - - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2026 - href: https://www.legislation.gov.uk/nisr/2026/2/made - title: Autumn Statement 2023 policy costings (April 2024 uplift maintained in cash terms thereafter) href: https://assets.publishing.service.gov.uk/media/655d0a83544aea000dfb321d/Autumn_Statement_2023_Policy_Costings_-_Final.pdf#page=16 metadata: diff --git a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml index 9cf92c8e56..fdbc9884be 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml @@ -1,4 +1,4 @@ -description: Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. The published tables give the 30th percentile; another value scales it by the ratio of the two percentiles in the model's list of rents for the same area and category (in Wales, Scotland and Northern Ireland, whose lists in the file are copies of English ones, the median English ratio for the category). This parameter does not apply if LHA is frozen, or to determinations before April 2020, which are taken as published. +description: Great Britain Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. The published tables give the 30th percentile; another value scales it by the ratio of the two percentiles in the model's list of rents for the same area and category (in Wales, whose lists in the file are copies of English ones, the median English ratio for the category). Northern Ireland rates follow gov.dfc.LHA.percentile. This parameter does not apply if LHA is frozen, or to determinations before April 2020, which are taken as published. values: 2015-01-01: value: 0.3 diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml new file mode 100644 index 0000000000..860c60873a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/min_age.yaml @@ -0,0 +1,14 @@ +description: Minimum age for computed Carer's Allowance entitlement. +# Existing model coverage anchor, not the statutory commencement date. +# The age condition predates the model's supported 2015 fiscal year. +values: + 2015-01-01: 16 +metadata: + unit: year + period: year + label: Carer's Allowance minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 70(3) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/70 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 70(3) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/70 diff --git a/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml b/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml new file mode 100644 index 0000000000..4fcf8ad82d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/dla/mobility/higher_min_age.yaml @@ -0,0 +1,12 @@ +description: Minimum age for the higher DLA mobility component. +values: + 2001-04-09: 3 +metadata: + unit: year + period: year + label: Higher DLA mobility minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 73(1A)(a) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/73 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 73(1A)(a) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/73 diff --git a/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml b/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml new file mode 100644 index 0000000000..ceb4c75c13 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/dla/mobility/lower_min_age.yaml @@ -0,0 +1,12 @@ +description: Minimum age for the lower DLA mobility component. +values: + 2001-04-09: 5 +metadata: + unit: year + period: year + label: Lower DLA mobility minimum age + reference: + - title: Social Security Contributions and Benefits Act 1992, section 73(1A)(b) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/73 + - title: Social Security Contributions and Benefits (Northern Ireland) Act 1992, section 73(1A)(b) and (11) + href: https://www.legislation.gov.uk/ukpga/1992/7/section/73 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml new file mode 100644 index 0000000000..bc51f73542 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml @@ -0,0 +1,10 @@ +description: Weekly earnings disregarded from Pension Credit income for a lone parent, a carer, or a claimant or partner on a listed disability benefit or certified blind. It is the most that can be disregarded however many conditions are met. +values: + 2003-10-06: 20 +metadata: + unit: currency-GBP + period: week + label: Pension Credit higher earnings disregard + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI paras. 1, 3, 4 and 4A + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml new file mode 100644 index 0000000000..8e31a28658 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml @@ -0,0 +1,19 @@ +description: > + Benefit-unit awards whose receipt qualifies the benefit unit for the higher + Pension Credit earnings disregard (Sch. VI para. 4(1)(a)(vii), employment + and support allowance, from 27 October 2008). The model holds income-related + employment and support allowance on the benefit unit, as the claimant's + award; contributory employment and support allowance is a person-level + benefit in `disability_benefits`. +metadata: + label: Pension Credit higher earnings disregard benefit-unit disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a)(vii) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 +values: + 2003-10-06: [] + 2008-10-27: + - esa_income diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml new file mode 100644 index 0000000000..6aa826b95b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -0,0 +1,46 @@ +description: > + Person-level benefits whose receipt by the claimant or partner qualifies the + benefit unit for the higher Pension Credit earnings disregard (Sch. VI para. + 4(1)(a)). Income-related employment and support allowance, a benefit-unit + award, is in `disability_benefit_unit_benefits`. + `incapacity_benefit` stands for long-term incapacity benefit (para. + 4(1)(a)(i)); the model does not separate the short-term rates. Not modelled: + the war pensions mobility supplement (v), which has no variable; pension age + disability payment (iiia, from 21 October 2024), Scottish adult disability + living allowance (iva, from 21 March 2025) and adult disability payment + (viiia, from 21 March 2022), which have no separate variables. Working tax + credit with its disability or severe disability element (vi) is a + benefit-unit award, tested in the formula of + `pension_credit_earnings_disregard`. +metadata: + label: Pension Credit higher earnings disregard disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) (ESA, from 27 October 2008) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 + - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 27(8)(b) (PIP, from 8 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/27 + - title: "The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions: Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013)" + href: https://www.legislation.gov.uk/uksi/2013/591/schedule/paragraph/23 +values: + 2003-10-06: + - incapacity_benefit + - sda + - attendance_allowance + - dla + 2008-10-27: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + 2013-04-08: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + - pip + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml new file mode 100644 index 0000000000..80ffae79fe --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml @@ -0,0 +1,20 @@ +description: Weekly earnings disregarded from Pension Credit income where neither the claimant nor a partner qualifies for the higher disregard. +SINGLE: + values: + 2003-10-06: 5 + metadata: + label: Pension Credit standard earnings disregard (single) +COUPLE: + values: + 2003-10-06: 10 + metadata: + label: Pension Credit standard earnings disregard (couple) +metadata: + unit: currency-GBP + period: week + propagate_metadata_to_children: true + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 5 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/5 + - title: The State Pension Credit Regulations 2002 reg. 17(9) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/17 diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml new file mode 100644 index 0000000000..7636be7603 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/enhanced.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 2 daily living activities qualifies for the enhanced rate of the PIP daily living component. +values: + 2013-04-08: 12 +metadata: + unit: integer + label: PIP daily living enhanced rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(3)(b) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml new file mode 100644 index 0000000000..40ca999230 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/daily_living/points_thresholds/standard.yaml @@ -0,0 +1,11 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 2 daily living activities qualifies for the standard rate of the PIP daily living component. +values: + 2013-04-08: 8 +metadata: + unit: integer + label: PIP daily living standard rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 5(3)(a) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/5 + - title: PIP assessment guide for assessment providers — Part 2 (Daily Living) + href: https://www.gov.uk/government/publications/personal-independence-payment-assessment-guide-for-assessment-providers diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml new file mode 100644 index 0000000000..8a388e39ce --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/enhanced.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 3 mobility activities qualifies for the enhanced rate of the PIP mobility component. +values: + 2013-04-08: 12 +metadata: + unit: integer + label: PIP mobility enhanced rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(3)(b) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml new file mode 100644 index 0000000000..4d550e55f7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pip/mobility/points_thresholds/standard.yaml @@ -0,0 +1,9 @@ +description: A claimant scoring at least this many points across the Schedule 1 Part 3 mobility activities qualifies for the standard rate of the PIP mobility component. +values: + 2013-04-08: 8 +metadata: + unit: integer + label: PIP mobility standard rate points threshold + reference: + - title: Social Security (Personal Independence Payment) Regulations 2013, reg. 6(3)(a) + href: https://www.legislation.gov.uk/uksi/2013/377/regulation/6 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md new file mode 100644 index 0000000000..e48bb342f4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md @@ -0,0 +1 @@ +# Benefit cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md new file mode 100644 index 0000000000..1cd731ff55 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md @@ -0,0 +1 @@ +# Earnings exception diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml new file mode 100644 index 0000000000..5a35f0314c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml @@ -0,0 +1,17 @@ +description: >- + Fixed monthly earnings threshold of the Universal Credit benefit cap's + earnings exception before 1 April 2017. Regulation 82(1)(a) as made lifted + the cap where the claimant's earned income, or a couple's combined earned + income, was at least this amount. From 1 April 2017 the threshold is the pay + for a number of hours a week at the national living wage instead. +values: + 2013-04-29: 430 +metadata: + unit: currency-GBP + period: month + label: Universal Credit benefit cap earnings exception fixed threshold + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a), as made + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml new file mode 100644 index 0000000000..29956f0ff4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml @@ -0,0 +1,22 @@ +description: >- + Weekly hours at the national living wage that set the earnings threshold of + the Universal Credit benefit cap's earnings exception. Regulation 82(1)(a) + lifts the cap where the claimant's earned income, or a couple's combined + earned income, is at least the pay for these hours at the hourly rate in + regulation 4 of the National Minimum Wage Regulations, converted to a monthly + amount by multiplying by 52 and dividing by 12. Fractions of a pound in that + monthly amount are disregarded (regulation 6(1A)(za)). Before 1 April 2017 the + threshold was a fixed monthly amount instead. +values: + 2017-04-01: 16 +metadata: + unit: hour + period: week + label: Universal Credit benefit cap earnings exception hours + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 6(1A)(za) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 regs. 1 and 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 8b67c2f92e..0c3d6284ca 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -661,7 +661,9 @@ obr: 2022-01-01: 0.1019 2023-01-01: 0.0532 2024-01-01: 0.0372 - # Forecast (2025-2030) from OBR EFO March 2026, Table A.1 nominal GDP minus population growth + # Forecast (2025-2030) from OBR EFO March 2026, Table A.1 nominal GDP minus population growth. + # Population growth here is ons.population (March 2025 vintage), not the + # lower growth implied by the March 2026 EFO; see ons.population. 2025-01-01: 0.0438 2026-01-01: 0.0292 2027-01-01: 0.0323 @@ -684,6 +686,7 @@ obr: 2023-01-01: -0.0060 2024-01-01: 0.0273 # Forecast (2025-2030) from OBR EFO March 2026, Table 1.12 minus population growth + # (ons.population, March 2025 vintage; see ons.population). 2025-01-01: 0.0069 2026-01-01: 0.0306 2027-01-01: 0.0371 @@ -706,6 +709,7 @@ obr: 2023-01-01: 0.1084 2024-01-01: 0.0385 # Forecast (2025-2030) from OBR EFO March 2026, Table 1.12 minus population growth + # (ons.population, March 2025 vintage; see ons.population). 2025-01-01: 0.0275 2026-01-01: 0.0358 2027-01-01: 0.0474 @@ -822,6 +826,19 @@ obr: ons: population: description: Population year-on-year growth. + # 2022-2073 is the population growth row of the OBR's long-term economic + # determinants for the March 2025 EFO, published 19 June 2025 (#1305). + # The fiscal year starting in year t is stored at t-01-01, rounded to + # four decimals (2025-26's 0.7230% is 2025-01-01: 0.0072). The OBR notes + # that readings are at the start of the fiscal year or mid-year. To + # 2029-30 the series is the OBR's March 2025 forecast; from 2030-31 it is + # the migration category variant of the ONS 2022-based national + # population projections. The 2021 value predates #1305 and has no + # recorded source. This series is a March 2025 vintage. Calendar-year + # population growth implied by the March 2026 EFO economy tables (real GDP + # in Table 1.1 over real GDP per person in Table 1.5) is lower: 0.37% in + # 2025, against 0.72% here, and 0.30% to 0.36% a year in 2026-2030. The + # ONS has since published 2024-based projections (28 April 2026). values: 2021-01-01: 0.004 2022-01-01: 0.0093 @@ -880,8 +897,10 @@ ons: unit: /1 label: Population growth reference: - - title: ONS Population Projections - href: https://www.ons.gov.uk/ + - title: OBR, "Long-term economic determinants - March 2025 Economic and fiscal outlook" (19 June 2025), population growth row + href: https://obr.uk/supplementary-forecast-information-on-long-term-economic-determinants-and-personal-independence-payment-policy-costing/ + - title: ONS, "National population projections - 2022-based" (migration category variant, used by the OBR from 2030-31) + href: https://www.ons.gov.uk/peoplepopulationandcommunity/populationandmigration/populationprojections/bulletins/nationalpopulationprojections/2022based household_interest_income: description: Household interest income year-on-year growth from ONS National Accounts D.41g. values: @@ -1197,12 +1216,27 @@ ons: ofwat: water_bills: description: Water and sewerage bills year-on-year growth. + # Growth in the average household water and sewerage bill in England and + # Wales. Each value is the charging year starting in April, so 2025-01-01 + # holds 2025-26. + # - 2022-2024: nominal growth in Ofwat's average bill, from the real + # bills and CPIH in policyengine_uk/utils/water/forecast_water_bills.py. + # - 2025 (2025-26): 27%, the increase Ofwat reports for 2025-26 in its + # 29 January 2026 statement. It replaces the 26% forecast in Ofwat's + # 30 January 2025 statement. + # - 2026 (2026-27): 5.4%, the national average from Water UK's 28 January + # 2026 company charges (GBP 639, up GBP 33), confirmed by Ofwat the next day. + # - 2027-2029: projections from forecast_water_bills.py (#1239). They are + # the customer-weighted mean of per-company bills before inflation, from + # the CCW table of Ofwat's PR24 draft determinations, plus a hardcoded + # CPIH path. The weights were in ofwat_increases.csv, which was never + # committed, so these three values cannot be reproduced from the repo. values: 2022-01-01: 0.052 2023-01-01: 0.092 2024-01-01: 0.044 - 2025-01-01: 0.26 - 2026-01-01: 0.055 + 2025-01-01: 0.27 + 2026-01-01: 0.054 2027-01-01: 0.046 2028-01-01: 0.045 2029-01-01: 0.045 @@ -1210,10 +1244,59 @@ ofwat: unit: /1 label: Water bills growth reference: - - title: Ofwat (and custom projections) - href: https://www.ofwat.gov.uk/price-review/ - - title: Ofwat 2025-26 media statement + - title: Ofwat, "Ofwat responds to water sector average bill figures" (29 January 2026) + href: https://www.ofwat.gov.uk/ofwat-responds-to-water-sector-average-bill-figures/ + - title: Water UK, "Record investment as average household water bills rise £33 a year from April" (28 January 2026) + href: https://www.water.org.uk/news-views-publications/news/record-investment-average-household-water-bills-rise-ps33-year-april + - title: Ofwat, "Average bills 2025/26 press statement" (30 January 2025) href: https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/ + - title: CCW, "Breakdown of water companies' bill increases 2025-30" (Ofwat PR24 draft determinations) + href: https://www.ccw.org.uk/our-work/price-review/how-much-will-my-water-and-sewerage-bills-increase-by-2030/breakdown-of-water-companies-bill-increases-2025-30/ +scottish_water: + water_bills: + description: Year-on-year growth in Scottish Water's household water and sewerage charges. + # Scottish Water sets Scotland's household charges within limits from the + # Water Industry Commission for Scotland (WICS); Ofwat's series covers + # England and Wales only. Each value is the charging year starting in + # April, so 2025-01-01 holds 2025-26. + # - 2022-2026: the increase in household charges that Scottish Water + # announced and WICS approved for each year. Every council tax band's + # charge is a fixed fraction of Band D, so each band rises by the same + # percentage. + # - 2027-2029: the core cap in WICS's Draft Determination for 2027-33 + # (consultation 30 June to 1 September 2026), CPI + 2% in each of the + # first three years (para. 9.1.3), with CPI from WICS's forecast of + # October CPI in the previous year, 1.9%, 1.9% and 2.1% (Table 68, from + # OBR's March 2026 forecast). WICS publishes the Final Determination on + # 29 October 2026; replace these with it. + # Later years hold the 2029-30 rate. + values: + 2022-01-01: 0.042 + 2023-01-01: 0.05 + 2024-01-01: 0.088 + 2025-01-01: 0.099 + 2026-01-01: 0.0867 + 2027-01-01: 0.039 + 2028-01-01: 0.039 + 2029-01-01: 0.041 + metadata: + unit: /1 + label: Scottish Water household charges growth + reference: + - title: WICS, Scottish Water's charges scheme 2022-23 (4.2%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23 + - title: WICS, Scottish Water's charges scheme 2023-24 (5%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24 + - title: WICS, Scottish Water's charges scheme 2024-25 (8.8%, letter of 30 January 2024) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25 + - title: Scottish Water, "Charges 2025 to 2026" (28 January 2025; 9.9%) + href: https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement + - title: Scottish Water, "Average water bills to rise £42 in 2026" (22 January 2026; 8.67%) + href: https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026 + - title: WICS, Strategic Review of Charges 2027-33 Draft Determination (June 2026), para. 9.1.3 (CPI + 2% core cap) and Table 68 (CPI forecast, p. 258) + href: https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf + - title: WICS, "WICS publishes 2027-33 Draft Determination" (Final Determination 29 October 2026) + href: https://wics.scot/latest/wics-publishes-2027-33-draft-determination finance_ni: domestic_rates: description: Growth in the average Northern Ireland domestic rates bill (regional rate plus district rate), used to project the domestic_rates dataset input. diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index e6be77d34e..a838ad12dd 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -1,5 +1,5 @@ -"""Index the personal allowance, the basic rate limit and the equivalent NICs -thresholds by September CPI once their freeze ends. +"""Index the personal allowance, basic and savings starting-rate limits, and +the equivalent NICs thresholds by September CPI once their freeze ends. Income tax ---------- @@ -12,6 +12,13 @@ the same percentage and round the result up to a multiple of £100. Each year starts from the rounded amount of the year before. +s21(3A) similarly raises the savings starting-rate limit, but rounds the +full result up to a multiple of £10. Finance Act 2026 s9 fixes that annual +limit at £5,000 through 2030-31 and disapplies s21 for those years. From +2031-32, ``index_savings_starting_rate_limit`` applies the statutory step +to the previous rounded year's limit. Future September CPI is a model +forecast, not a known statutory future amount. + Finance Act 2021 s5, as amended by Finance Act 2023 s5 and Finance Act 2026 s10, sets the basic rate limit at £37,700 and the personal allowance at £12,570 for 2022-23 to 2030-31 and disapplies ss21 and 57 for those years. @@ -85,7 +92,7 @@ (a reform dict, ``parameter_changes``, ``Reform.from_dict``) writes its changes onto the parameters before they are processed, as does a simulation modifier that resets and reprocesses them (as ``no_economic_assumptions`` does), so the -values are rebuilt with the change: a changed CPI forecast moves all seven, +values are rebuilt with the change: a changed CPI forecast moves all eight, and a change to the personal allowance or basic rate limit after 2030-31 also moves the aligned NICs thresholds (see ``policyengine_uk.utils.dated_reforms``). The exceptions edit the processed parameters, so they do not move the others: @@ -105,6 +112,8 @@ # ITA 2007 s57(3) Step 2 and s21(3) Step 2. PERSONAL_ALLOWANCE_MULTIPLE = Decimal(10) BASIC_RATE_LIMIT_MULTIPLE = Decimal(100) +# ITA 2007 s21(3A) Step 2. +SAVINGS_STARTING_RATE_LIMIT_MULTIPLE = Decimal(10) PENNY = Decimal("0.01") # Fiscal-year conversion reads each tax year's value at 30 April. FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30" @@ -124,11 +133,12 @@ INDEXED_THRESHOLDS = ( "gov.hmrc.income_tax.allowances.personal_allowance.amount", "gov.hmrc.income_tax.rates.uk[1].threshold", - "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", - "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", - "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", - "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", - "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", + "gov.hmrc.national_insurance.class_1.employee_scale[1].threshold", + "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold", + "gov.hmrc.national_insurance.class_1.employer_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[2].threshold", ) @@ -162,6 +172,14 @@ def index_basic_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: return round_up_to_multiple(step_1, BASIC_RATE_LIMIT_MULTIPLE) +def index_savings_starting_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: + """ITA 2007 s21(1) and (3A): next year's annual savings starting limit.""" + if increase <= 0: + return previous + amount = previous * (1 + increase) + return round_up_to_multiple(amount, SAVINGS_STARTING_RATE_LIMIT_MULTIPLE) + + def weekly_equivalent(annual: Decimal) -> Decimal: """Annual amount over 52, to the penny, as the parameter files store it.""" return (annual / WEEKS_IN_YEAR).quantize(PENNY, ROUND_HALF_UP) @@ -307,18 +325,22 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: """ increases = september_cpi_increases(parameters) income_tax = parameters.gov.hmrc.income_tax - class_1 = parameters.gov.hmrc.national_insurance.class_1.thresholds - class_4 = parameters.gov.hmrc.national_insurance.class_4.thresholds + class_1 = parameters.gov.hmrc.national_insurance.class_1 + class_4 = parameters.gov.hmrc.national_insurance.class_4 personal_allowance_parameter = income_tax.allowances.personal_allowance.amount basic_rate_limit_parameter = income_tax.rates.uk.brackets[1].threshold + savings_starting_rate_limit_parameter = ( + income_tax.rates.savings_starter_rate.allowance + ) indexed = [ personal_allowance_parameter, basic_rate_limit_parameter, - class_1.primary_threshold, - class_1.upper_earnings_limit, - class_1.secondary_threshold, - class_4.lower_profits_limit, - class_4.upper_profits_limit, + savings_starting_rate_limit_parameter, + class_1.employee_scale.brackets[1].threshold, + class_1.employee_scale.brackets[2].threshold, + class_1.employer_scale.brackets[1].threshold, + class_4.contribution_scale.brackets[1].threshold, + class_4.contribution_scale.brackets[2].threshold, ] # From the earliest last value in the files, so the income tax amounts # cover every year a NICs threshold takes from them. @@ -333,29 +355,47 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: basic_rate_limit = tax_year_path( basic_rate_limit_parameter, years, increases, index_basic_rate_limit ) + savings_starting_rate_limit = tax_year_path( + savings_starting_rate_limit_parameter, + years, + increases, + index_savings_starting_rate_limit, + ) higher_rate_threshold = { year: personal_allowance[year] + basic_rate_limit[year] for year in years } secondary_threshold = tax_year_path( - class_1.secondary_threshold, years, increases, index_secondary_threshold + class_1.employer_scale.brackets[1].threshold, + years, + increases, + index_secondary_threshold, ) write_unstated_tax_years(personal_allowance_parameter, personal_allowance) write_unstated_tax_years(basic_rate_limit_parameter, basic_rate_limit) - write_unstated_tax_years(class_4.lower_profits_limit, personal_allowance) - write_unstated_tax_years(class_4.upper_profits_limit, higher_rate_threshold) write_unstated_tax_years( - class_1.primary_threshold, + savings_starting_rate_limit_parameter, savings_starting_rate_limit + ) + write_unstated_tax_years( + class_4.contribution_scale.brackets[1].threshold, personal_allowance + ) + write_unstated_tax_years( + class_4.contribution_scale.brackets[2].threshold, higher_rate_threshold + ) + write_unstated_tax_years( + class_1.employee_scale.brackets[1].threshold, {year: weekly_equivalent(value) for year, value in personal_allowance.items()}, ) write_unstated_tax_years( - class_1.upper_earnings_limit, + class_1.employee_scale.brackets[2].threshold, { year: weekly_equivalent(value) for year, value in higher_rate_threshold.items() }, ) - write_unstated_tax_years(class_1.secondary_threshold, secondary_threshold) + write_unstated_tax_years( + class_1.employer_scale.brackets[1].threshold, secondary_threshold + ) for parameter in indexed: (parameter.metadata or {}).pop(FILES_LAST_TAX_YEAR, None) return parameters diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md index 6ab9344984..bc532db98a 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md @@ -1 +1,10 @@ # Savings starter rate + +The annual starting-rate limit remains £5,000 through 2030–31 under Finance +Act 2026 section 9. From 2031–32, Income Tax Act 2007 section 21(3A) increases +the preceding rounded limit by the positive September CPI rise and rounds the +full amount upward to a multiple of £10. Zero or negative CPI leaves the +limit unchanged. `create_threshold_indexation.py` generates these annual +values through the economic-assumptions horizon; future CPI inputs are +forecasts or scenario assumptions. The pre-2015 parameter history is retained +without being verified by this prospective change. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml index bb57a28247..3cc8f71baa 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml @@ -1,10 +1,33 @@ -description: Starter allowance for savings. +description: Annual starting-rate limit for savings income. +# ITA 2007 s21(3A) increases the previous rounded annual limit by positive +# September CPI and rounds the result up to a multiple of £10. FA 2026 s9 +# fixes £5,000 through 2030-31 and disapplies that indexation. From 2031-32, +# create_threshold_indexation.py applies the statutory step, so this file +# has no generic uprating. The pre-2015 model entry is retained unchanged; +# this prospective indexation fix does not verify that historical amount. values: 2010-04-01: 5_000 + 2015-04-06: + value: 5_000 + metadata: + reference: + - title: Finance Act 2014 s. 3 (2) and (3) + href: https://www.legislation.gov.uk/ukpga/2014/26/section/3 + 2026-04-06: 5_000 + 2027-04-06: 5_000 + 2028-04-06: 5_000 + 2029-04-06: 5_000 + 2030-04-06: 5_000 metadata: - period: week + period: year unit: currency-GBP label: Savings starter rate allowance - reference: - - title: Tax on savings interest - href: https://www.gov.uk/apply-tax-free-interest-on-savings + reference: + - title: Income Tax Act 2007 s. 12 (3) and (4) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/12 + - title: Income Tax Act 2007 s. 21 (1), (3A), (5) and (6) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/21 + - title: Finance Act 2026 s. 9 (freeze for 2026-27 to 2030-31) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/9 + - title: Tax on savings interest + href: https://www.gov.uk/apply-tax-free-interest-on-savings diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml new file mode 100644 index 0000000000..81fb7cd75f --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/employee_scale.yaml @@ -0,0 +1,23 @@ +description: The annual primary Class 1 National Insurance schedule for a full-year category A director in one employment in 2022–23. +brackets: + - threshold: + 2022-04-06: 0 + rate: + 2022-04-06: 0 + - threshold: + 2022-04-06: 11908 + rate: + 2022-04-06: 0.1273 + - threshold: + 2022-04-06: 50270 + rate: + 2022-04-06: 0.0273 +metadata: + label: Full-year category A director annual employee NI 2022–23 + threshold_unit: currency-GBP + rate_unit: /1 + reference: + - title: HMRC rates and thresholds for employers 2022 to 2023, annual director primary thresholds and category A rates + href: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 + - title: Social Security (Contributions) Regulations 2001, regulation 8(3), annual earnings period + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/8 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml new file mode 100644 index 0000000000..d080a26b80 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23/in_effect.yaml @@ -0,0 +1,11 @@ +description: The model applies the full-year category A director employee schedule for the supported 2022–23 tax year. +values: + 0000-01-01: false + 2022-04-06: true + 2023-04-06: false +metadata: + label: Full-year category A director 2022–23 implementation coverage + unit: bool + reference: + - title: HMRC rates and thresholds for employers 2022 to 2023, company directors + href: https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml new file mode 100644 index 0000000000..fa604f14b4 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employee_scale.yaml @@ -0,0 +1,244 @@ +description: Class 1 employee marginal contribution schedule. +metadata: + label: Class 1 employee marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: currency-GBP + period: week + rate: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: /1 + - threshold: + description: The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance. + # From 6 July 2022 the model stores the annual earnings period threshold + # divided by 52 (12,570 / 52, rounded to 241.73), as SI 2001/1004 reg 11(3)(c) + # does for periods of whole weeks; 52 weeks give £12,569.96. The weekly amount + # in reg 10(c) is £242. + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model keeps the + # threshold at the personal allowance over 52, to the penny, as for 241.73 + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the PT aligned with the personal allowance, which Income + # Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the PT will + # be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI + # 2026/231 para 5.1). That is announced policy, not law; keeping it equal to + # the allowance, round-up included, is a modelling assumption consistent with + # it. The file has no uprating. + values: + 2015-04-06: 155 + 2016-04-06: 155 + 2017-04-06: 157 + 2018-04-06: 162 + 2019-04-06: 166 + 2020-04-06: 183 + 2021-04-06: 184 + 2022-04-06: + value: 190 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2022(6) + href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a + 2022-07-01: + value: 241.73 + metadata: + reference: + - title: Spring Statement 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1062486/Spring_Statement_2022_Web_Accessible.pdf + 2026-04-06: + value: 241.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(c) and reg 11(3), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 241.73 + metadata: + reference: + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 241.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI primary threshold + unit: currency-GBP + period: week + reference: + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit. + values: + 2015-04-01: 0.12 + 2022-04-06: + value: 0.1325 + metadata: + reference: + - title: Health and Social Care Levy Act 2021 s.5(2)(a)(i) + href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted + - title: Autumn Budget and Spending Review 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf + 2022-11-06: + value: 0.12 + metadata: + reference: + - title: Health and Social Care Levy Act (Repeal) 2022 + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2024-01-01: + value: 0.1 + metadata: + reference: + - title: "Autumn Statement 2023: National Insurance Factsheet" + href: https://www.gov.uk/government/publications/autumn-statement-2023-national-insurance-factsheet/autumn-statement-2023-national-insurance-factsheet + 2024-04-01: + value: 0.08 + metadata: + reference: + - title: National Insurance Contributions (Reduction in Rates) Act 2024 s 1 + href: https://www.legislation.gov.uk/ukpga/2024/5/section/1 + metadata: + label: NI class 1 main rate + unit: /1 + reference: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - threshold: + description: The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance. + # From 2021-22 the model stores the annual earnings period limit divided by 52 + # (50,270 / 52, rounded to 966.73), as SI 2001/1004 reg 11(2A)(c) does for + # periods of whole weeks; 52 weeks give £50,269.96. The weekly amount in + # reg 10(b) is £967. + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model keeps the + # limit at the higher rate threshold (personal allowance plus basic rate + # limit) over 52, to the penny, as for 966.73 + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # UEL has been aligned with the higher rate threshold since April 2009, and + # for 2026-27 "will remain at a level equivalent to the annual HRT" + # (Explanatory Memorandum to SI 2026/231 paras 5.14-5.15); para 5.1 says it + # will be uprated by September CPI after 2030-31. Income Tax Act 2007 ss21 and + # 57 index the higher rate threshold by September CPI from 2031-32. Keeping + # the UEL equal to it, round-ups included, is a modelling assumption + # consistent with that announced policy, not law. The file has no uprating. + values: + 2015-04-06: 815 + 2016-04-06: 827 + 2017-04-06: 866 + 2018-04-06: 892 + 2019-04-06: 962 + 2020-04-06: 962 + 2021-04-06: 966.73 + 2026-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(b) and reg 11(2A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 966.73 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI upper earnings limit + unit: currency-GBP + period: week + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit. + values: + 2015-04-01: 0.02 + 2022-04-01: + value: 0.0325 + metadata: + reference: + - title: Autumn Budget and Spending Review 2022 + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf + 2024-04-04: + value: 0.02 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 + href: https://www.legislation.gov.uk/ukpga/1992/4/section/8 + metadata: + label: NI class 1 additional rate + unit: /1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml new file mode 100644 index 0000000000..03a0362bed --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/employer_scale.yaml @@ -0,0 +1,161 @@ +description: Class 1 employer marginal contribution schedule. +metadata: + label: Class 1 employer marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: currency-GBP + period: week + rate: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: /1 + - threshold: + description: Secondary threshold for National Insurance. + metadata: + label: NI secondary threshold + period: week + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + unit: currency-GBP + # From 2025-26 the value is the annual £5,000 in SI 2001/1004 reg 11(3A)(b) + # divided by 52 (96.153846 = 5,000 / 52), so that the model's 52-week + # annualisation gives £5,000. This is the convention reg 11(3)(c) and + # 11(2A)(c) use for the primary threshold and upper earnings limit, which the + # model also stores as annual / 52. The rounded weekly figure in reg 10(d) is + # £96, which would annualise to £4,992 (#1967). Before 2025-26 the weekly + # figure times 52 equals the annual one (for example £175 x 52 = £9,100). + # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The + # 2027-28 to 2030-31 values are announced policy (Autumn Budget 2024 and + # Budget 2025), to be set in each year's rates, limits and thresholds + # regulations. + # From 2031-32 (the tax year after the last value below) the model raises the + # threshold each year by September CPI of the previous year, unrounded + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # Autumn Budget 2024 note said the threshold "will be increased in line with + # Consumer Prices Index (CPI)" after its freeze; nothing later sets the rule, + # so this is a modelling assumption, and the file has no uprating. + values: + # £155 was the primary threshold in 2015-16 and 2016-17 (reg 10(c)), not the + # secondary threshold (#2178). + 2015-04-06: + value: 156 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2015 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2015-04-06 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2015 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2015-04-06 + 2016-04-06: + value: 156 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2016 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2016 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2016-04-06 + 2017-04-06: 157 + 2018-04-06: 162 + 2019-04-06: 166 + 2020-04-06: 169 + 2021-04-06: 170 + 2022-04-06: + metadata: + reference: + - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a + title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments + and National Insurance Funds Payments) Regulations 2022(6) + value: 175 + 2024-01-01: 175 + 2025-04-06: + value: 96.153846 + metadata: + reference: + - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.2 + href: https://www.legislation.gov.uk/ukpga/2025/11/section/2 + - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£5,000 where the earnings period is a year) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11 + 2026-04-06: + value: 96.153846 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(d) and reg 11(3A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + - title: SI 2026/231 reg 5 + href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made + 2027-04-06: + value: 96.153846 + metadata: + reference: + - title: "HMRC: Changes to the Class 1 National Insurance Contributions Secondary Threshold, the Secondary Class 1 National Insurance contributions rate, and the Employment Allowance from 6 April 2025 (Autumn Budget 2024)" + href: https://www.gov.uk/government/publications/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl + 2028-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 96.153846 + metadata: + reference: + - title: Budget 2025 (HC 1492) para 4.112 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: Explanatory Memorandum to SI 2026/231 para 5.26 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + rate: + description: National Insurance contribution rate by employers on earnings above the + Secondary Threshold. + metadata: + label: NI employer rate + propagate_metadata_to_children: true + unit: /1 + # Annual values are the rate at 30 April of each tax year, as for the + # employee main rate (no fiscal_year_blend). For 2022-23 that is the levy + # rate of 15.05%, although earnings paid from 6 November 2022 were charged + # at 13.8%; see #1807 for the choice between sampling and averaging. + values: + 2015-04-01: + value: 0.138 + metadata: + reference: + - title: National Insurance rates and thresholds + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + 2022-04-06: + value: 0.1505 + metadata: + reference: + - title: Health and Social Care Levy Act 2021 s.5(2)(b) + href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted + 2022-11-06: + value: 0.138 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s.2(1) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2/enacted + 2025-01-01: + value: 0.15 + metadata: + reference: + - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.1 + href: https://www.legislation.gov.uk/ukpga/2025/11/section/1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml deleted file mode 100644 index 717968b8a2..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/additional.yaml +++ /dev/null @@ -1,18 +0,0 @@ -description: The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit. -values: - 2015-04-01: 0.02 - 2022-04-01: - value: 0.0325 - metadata: - reference: - - title: Autumn Budget and Spending Review 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf - 2024-04-04: - value: 0.02 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 - href: https://www.legislation.gov.uk/ukpga/1992/4/section/8 -metadata: - label: NI class 1 additional rate - unit: /1 \ No newline at end of file diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml deleted file mode 100644 index f7e10bfa97..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employee/main.yaml +++ /dev/null @@ -1,33 +0,0 @@ -description: The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit. -values: - 2015-04-01: 0.12 - 2022-04-06: - value: 0.1325 - metadata: - reference: - - title: Health and Social Care Levy Act 2021 s.5(2)(a)(i) - href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted - - title: Autumn Budget and Spending Review 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1043689/Budget_AB2021_Web_Accessible.pdf - 2022-11-06: - value: 0.12 - metadata: - reference: - - title: Health and Social Care Levy Act (Repeal) 2022 - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2024-01-01: - value: 0.1 - metadata: - reference: - - title: "Autumn Statement 2023: National Insurance Factsheet" - href: https://www.gov.uk/government/publications/autumn-statement-2023-national-insurance-factsheet/autumn-statement-2023-national-insurance-factsheet - 2024-04-01: - value: 0.08 - metadata: - reference: - - title: National Insurance Contributions (Reduction in Rates) Act 2024 s 1 - href: https://www.legislation.gov.uk/ukpga/2024/5/section/1 -metadata: - label: NI class 1 main rate - unit: /1 - reference: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml deleted file mode 100644 index 55ca38508c..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/rates/employer.yaml +++ /dev/null @@ -1,35 +0,0 @@ -description: National Insurance contribution rate by employers on earnings above the - Secondary Threshold. -metadata: - label: NI employer rate - propagate_metadata_to_children: true - unit: /1 - # Annual values are the rate at 30 April of each tax year, as for the - # employee main rate (no fiscal_year_blend). For 2022-23 that is the levy - # rate of 15.05%, although earnings paid from 6 November 2022 were charged - # at 13.8%; see #1807 for the choice between sampling and averaging. -values: - 2015-04-01: - value: 0.138 - metadata: - reference: - - title: National Insurance rates and thresholds - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - 2022-04-06: - value: 0.1505 - metadata: - reference: - - title: Health and Social Care Levy Act 2021 s.5(2)(b) - href: https://www.legislation.gov.uk/ukpga/2021/28/section/5/enacted - 2022-11-06: - value: 0.138 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s.2(1) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2/enacted - 2025-01-01: - value: 0.15 - metadata: - reference: - - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.1 - href: https://www.legislation.gov.uk/ukpga/2025/11/section/1 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml deleted file mode 100644 index 2f934569d5..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml +++ /dev/null @@ -1,89 +0,0 @@ -description: The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance. -# From 6 July 2022 the model stores the annual earnings period threshold -# divided by 52 (12,570 / 52, rounded to 241.73), as SI 2001/1004 reg 11(3)(c) -# does for periods of whole weeks; 52 weeks give £12,569.96. The weekly amount -# in reg 10(c) is £242. -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model keeps the -# threshold at the personal allowance over 52, to the penny, as for 241.73 -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the PT aligned with the personal allowance, which Income -# Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the PT will -# be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI -# 2026/231 para 5.1). That is announced policy, not law; keeping it equal to -# the allowance, round-up included, is a modelling assumption consistent with -# it. The file has no uprating. -values: - 2015-04-06: 155 - 2016-04-06: 155 - 2017-04-06: 157 - 2018-04-06: 162 - 2019-04-06: 166 - 2020-04-06: 183 - 2021-04-06: 184 - 2022-04-06: - value: 190 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2022(6) - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a - 2022-07-01: - value: 241.73 - metadata: - reference: - - title: Spring Statement 2022 - href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/1062486/Spring_Statement_2022_Web_Accessible.pdf - 2026-04-06: - value: 241.73 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(c) and reg 11(3), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 241.73 - metadata: - reference: - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 241.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI primary threshold - unit: currency-GBP - period: week - reference: - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml deleted file mode 100644 index d5cac1d7e9..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml +++ /dev/null @@ -1,103 +0,0 @@ -description: Secondary threshold for National Insurance. -metadata: - label: NI secondary threshold - period: week - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions - unit: currency-GBP -# From 2025-26 the value is the annual £5,000 in SI 2001/1004 reg 11(3A)(b) -# divided by 52 (96.153846 = 5,000 / 52), so that the model's 52-week -# annualisation gives £5,000. This is the convention reg 11(3)(c) and -# 11(2A)(c) use for the primary threshold and upper earnings limit, which the -# model also stores as annual / 52. The rounded weekly figure in reg 10(d) is -# £96, which would annualise to £4,992 (#1967). Before 2025-26 the weekly -# figure times 52 equals the annual one (for example £175 x 52 = £9,100). -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Budget 2024 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model raises the -# threshold each year by September CPI of the previous year, unrounded -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# Autumn Budget 2024 note said the threshold "will be increased in line with -# Consumer Prices Index (CPI)" after its freeze; nothing later sets the rule, -# so this is a modelling assumption, and the file has no uprating. -values: - # £155 was the primary threshold in 2015-16 and 2016-17 (reg 10(c)), not the - # secondary threshold (#2178). - 2015-04-06: - value: 156 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2015 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2015-04-06 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2015 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2015-04-06 - 2016-04-06: - value: 156 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) (£156 a week), as at 6 April 2016 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£8,112 where the earnings period is a year), as at 6 April 2016 - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2016-04-06 - 2017-04-06: 157 - 2018-04-06: 162 - 2019-04-06: 166 - 2020-04-06: 169 - 2021-04-06: 170 - 2022-04-06: - metadata: - reference: - - href: https://www.legislation.gov.uk/uksi/2022/232/regulation/6/made#regulation-6-2-a - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments - and National Insurance Funds Payments) Regulations 2022(6) - value: 175 - 2024-01-01: 175 - 2025-04-06: - value: 96.153846 - metadata: - reference: - - title: National Insurance Contributions (Secondary Class 1 Contributions) Act 2025 s.2 - href: https://www.legislation.gov.uk/ukpga/2025/11/section/2 - - title: The Social Security (Contributions) Regulations 2001 reg 11(3A)(b) (£5,000 where the earnings period is a year) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11 - 2026-04-06: - value: 96.153846 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(d) and reg 11(3A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 96.153846 - metadata: - reference: - - title: "HMRC: Changes to the Class 1 National Insurance Contributions Secondary Threshold, the Secondary Class 1 National Insurance contributions rate, and the Employment Allowance from 6 April 2025 (Autumn Budget 2024)" - href: https://www.gov.uk/government/publications/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl/changes-to-the-class-1-national-insurance-contributions-secondary-threshold-the-secondary-class-1-national-insurance-contributions-rate-and-the-empl - 2028-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 96.153846 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.112 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: Explanatory Memorandum to SI 2026/231 para 5.26 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml deleted file mode 100644 index 62ff414c47..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml +++ /dev/null @@ -1,79 +0,0 @@ -description: The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance. -# From 2021-22 the model stores the annual earnings period limit divided by 52 -# (50,270 / 52, rounded to 966.73), as SI 2001/1004 reg 11(2A)(c) does for -# periods of whole weeks; 52 weeks give £50,269.96. The weekly amount in -# reg 10(b) is £967. -# Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The -# 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and -# Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. -# From 2031-32 (the tax year after the last value below) the model keeps the -# limit at the higher rate threshold (personal allowance plus basic rate -# limit) over 52, to the penny, as for 966.73 -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# UEL has been aligned with the higher rate threshold since April 2009, and -# for 2026-27 "will remain at a level equivalent to the annual HRT" -# (Explanatory Memorandum to SI 2026/231 paras 5.14-5.15); para 5.1 says it -# will be uprated by September CPI after 2030-31. Income Tax Act 2007 ss21 and -# 57 index the higher rate threshold by September CPI from 2031-32. Keeping -# the UEL equal to it, round-ups included, is a modelling assumption -# consistent with that announced policy, not law. The file has no uprating. -values: - 2015-04-06: 815 - 2016-04-06: 827 - 2017-04-06: 866 - 2018-04-06: 892 - 2019-04-06: 962 - 2020-04-06: 962 - 2021-04-06: 966.73 - 2026-04-06: - value: 966.73 - metadata: - reference: - - title: The Social Security (Contributions) Regulations 2001 reg 10(b) and reg 11(2A), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) - href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 - - title: SI 2026/231 reg 5 - href: https://www.legislation.gov.uk/uksi/2026/231/regulation/5/made - 2027-04-06: - value: 966.73 - metadata: - reference: - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 966.73 - metadata: - reference: - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI upper earnings limit - unit: currency-GBP - period: week - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml new file mode 100644 index 0000000000..9bd326ca1f --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/contribution_scale.yaml @@ -0,0 +1,332 @@ +description: Class 4 marginal contribution schedule. +metadata: + label: Class 4 marginal contribution schedule + threshold_unit: currency-GBP + rate_unit: /1 +brackets: + - threshold: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: currency-GBP + period: year + rate: + values: + 0001-01-01: 0 + metadata: + household: false + economy: false + unit: /1 + - threshold: + description: The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate. + # Legal status: £12,570 is the standing figure in SSCBA 1992 s.15(3)(a), which + # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to + # keeping it until April 2031. From 2031-32 (the tax year after the last value + # below) the model keeps the limit equal to the personal allowance + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the LPL aligned with the personal allowance, which + # Income Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the + # LPL will be uprated by September CPI after 2030-31 (Explanatory Memorandum + # to SI 2026/231 para 5.1). That is announced policy, not law; keeping it + # equal to the allowance, round-up included, is a modelling assumption + # consistent with it. The file has no uprating. + values: + 2015-04-06: + value: 8_060 + metadata: + reference: + - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(a) + href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made + - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2016, unchanged for 2016-17) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2016-04-06 + 2017-04-06: + value: 8_164 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made + 2018-04-06: + value: 8_424 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made + 2019-04-06: + value: 8_632 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(a) + href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made + 2020-04-06: + value: 9_500 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2020 reg 5 + href: https://www.legislation.gov.uk/uksi/2020/299/regulation/5/made + 2021-04-06: + value: 9_568 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(1) + href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made + # SI 2022/232 reg 5 first set £9,880 for 2022-23; the 2022 Act replaced it + # with £11,908 for the whole year. + 2022-04-06: + value: 11_908 + metadata: + reference: + - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(1) and (2) + href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted + 2023-04-06: + value: 12_570 + metadata: + reference: + - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(3) and (4) + href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + 2026-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + 2027-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 12_570 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI lower profits limit + unit: currency-GBP + period: year + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits. + values: + 2015-04-06: + value: 0.09 + metadata: + reference: + - title: National Insurance Contributions Act 2011 s 2(1)(a) (main Class 4 percentage of 9% from 6 April 2011) + href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2015) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 + # Class 4 is charged on the year's profits, so 2022-23 has one rate for the + # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced + # the 10.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. + 2022-04-06: + value: 0.0973 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(a) (main Class 4 percentage of 9.73% for the tax year 2022-23) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2023-04-06: + value: 0.09 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + 2024-04-06: + value: 0.06 + metadata: + reference: + - title: National Insurance Contributions (Reduction in Rates) Act 2024 ss 1(3)(a) and 3(1) + href: https://www.legislation.gov.uk/ukpga/2024/5/section/1/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2024) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2024-04-06 + metadata: + label: NI class 4 main rate + unit: /1 + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + - threshold: + description: The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate. + # Legal status: £50,270 is the standing figure in SSCBA 1992 s.15(3)(b), which + # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to + # keeping it until April 2031. From 2031-32 (the tax year after the last value + # below) the model keeps the limit equal to the higher rate threshold + # (personal allowance plus basic rate limit) + # (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The + # government has kept the UPL in line with the higher rate threshold, which + # Income Tax Act 2007 ss21 and 57 index by September CPI from 2031-32, and + # says the UPL will be uprated by September CPI after 2030-31 (Explanatory + # Memorandum to SI 2026/231 paras 5.1 and 5.25). That is announced policy, not + # law; keeping it equal to the higher rate threshold, round-ups included, is a + # modelling assumption consistent with it. The file has no uprating. + values: + 2015-04-06: + value: 42_385 + metadata: + reference: + - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(b) + href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made + 2016-04-06: + value: 43_000 + metadata: + reference: + - title: The Social Security (Contributions) (Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2016 reg 2 + href: https://www.legislation.gov.uk/uksi/2016/343/regulation/2/made + 2017-04-06: + value: 45_000 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made + 2018-04-06: + value: 46_350 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made + 2019-04-06: + value: 50_000 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(b) + href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made + - title: Social Security Contributions and Benefits Act 1992 s 15(3) (as at 6 April 2020, unchanged for 2020-21) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2020-04-06 + 2021-04-06: + value: 50_270 + metadata: + reference: + - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(2) + href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made + 2026-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + 2027-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" + href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 + 2028-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2029-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + 2030-04-06: + value: 50_270 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Explanatory Memorandum to SI 2026/231 para 5.25 + href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf + metadata: + label: NI upper profits limit + unit: currency-GBP + period: year + reference: + - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf + - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions + rate: + description: The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits. + values: + 2015-04-06: + value: 0.02 + metadata: + reference: + - title: National Insurance Contributions Act 2011 s 2(1)(b) (additional Class 4 percentage of 2% from 6 April 2011) + href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2015) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 + # Class 4 is charged on the year's profits, so 2022-23 has one rate for the + # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced + # the 3.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. + 2022-04-06: + value: 0.0273 + metadata: + reference: + - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(b) (additional Class 4 percentage of 2.73% for the tax year 2022-23) + href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 + 2023-04-06: + value: 0.02 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2023) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 + metadata: + label: NI class 4 additional rate + unit: /1 + reference: + - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 + - title: "GOV.UK | Rates and allowances: National Insurance contributions" + href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml deleted file mode 100644 index 81d3c7d5c6..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/additional.yaml +++ /dev/null @@ -1,33 +0,0 @@ -description: The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits. -values: - 2015-04-06: - value: 0.02 - metadata: - reference: - - title: National Insurance Contributions Act 2011 s 2(1)(b) (additional Class 4 percentage of 2% from 6 April 2011) - href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2015) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 - # Class 4 is charged on the year's profits, so 2022-23 has one rate for the - # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced - # the 3.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. - 2022-04-06: - value: 0.0273 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(b) (additional Class 4 percentage of 2.73% for the tax year 2022-23) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2023-04-06: - value: 0.02 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(b) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 -metadata: - label: NI class 4 additional rate - unit: /1 - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml deleted file mode 100644 index 5f3dab92c3..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/rates/main.yaml +++ /dev/null @@ -1,41 +0,0 @@ -description: The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits. -values: - 2015-04-06: - value: 0.09 - metadata: - reference: - - title: National Insurance Contributions Act 2011 s 2(1)(a) (main Class 4 percentage of 9% from 6 April 2011) - href: https://www.legislation.gov.uk/ukpga/2011/3/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2015) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2015-04-06 - # Class 4 is charged on the year's profits, so 2022-23 has one rate for the - # whole tax year. The Health and Social Care Levy (Repeal) Act 2022 replaced - # the 10.25% that the Health and Social Care Levy Act 2021 s 5(2) had set. - 2022-04-06: - value: 0.0973 - metadata: - reference: - - title: Health and Social Care Levy (Repeal) Act 2022 s 2(2)(a) (main Class 4 percentage of 9.73% for the tax year 2022-23) - href: https://www.legislation.gov.uk/ukpga/2022/43/section/2 - 2023-04-06: - value: 0.09 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 - 2024-04-06: - value: 0.06 - metadata: - reference: - - title: National Insurance Contributions (Reduction in Rates) Act 2024 ss 1(3)(a) and 3(1) - href: https://www.legislation.gov.uk/ukpga/2024/5/section/1/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA)(a) (as at 6 April 2024) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2024-04-06 -metadata: - label: NI class 4 main rate - unit: /1 - reference: - - title: Social Security Contributions and Benefits Act 1992 s 15(3ZA) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "GOV.UK | Rates and allowances: National Insurance contributions" - href: https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml deleted file mode 100644 index ffb02e530a..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml +++ /dev/null @@ -1,124 +0,0 @@ -description: The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate. -# Legal status: £12,570 is the standing figure in SSCBA 1992 s.15(3)(a), which -# stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. From 2031-32 (the tax year after the last value -# below) the model keeps the limit equal to the personal allowance -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the LPL aligned with the personal allowance, which -# Income Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the -# LPL will be uprated by September CPI after 2030-31 (Explanatory Memorandum -# to SI 2026/231 para 5.1). That is announced policy, not law; keeping it -# equal to the allowance, round-up included, is a modelling assumption -# consistent with it. The file has no uprating. -values: - 2015-04-06: - value: 8_060 - metadata: - reference: - - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(a) - href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made - - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2016, unchanged for 2016-17) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2016-04-06 - 2017-04-06: - value: 8_164 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made - 2018-04-06: - value: 8_424 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made - 2019-04-06: - value: 8_632 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(a) - href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made - 2020-04-06: - value: 9_500 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2020 reg 5 - href: https://www.legislation.gov.uk/uksi/2020/299/regulation/5/made - 2021-04-06: - value: 9_568 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(1) - href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made - # SI 2022/232 reg 5 first set £9,880 for 2022-23; the 2022 Act replaced it - # with £11,908 for the whole year. - 2022-04-06: - value: 11_908 - metadata: - reference: - - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(1) and (2) - href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted - 2023-04-06: - value: 12_570 - metadata: - reference: - - title: National Insurance Contributions (Increase of Thresholds) Act 2022 s 2(3) and (4) - href: https://www.legislation.gov.uk/ukpga/2022/16/section/2/enacted - - title: Social Security Contributions and Benefits Act 1992 s 15(3)(a) (as at 6 April 2023) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2023-04-06 - 2026-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - 2027-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 12_570 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(a) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI lower profits limit - unit: currency-GBP - period: year - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml deleted file mode 100644 index 74e475204d..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml +++ /dev/null @@ -1,109 +0,0 @@ -description: The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate. -# Legal status: £50,270 is the standing figure in SSCBA 1992 s.15(3)(b), which -# stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. From 2031-32 (the tax year after the last value -# below) the model keeps the limit equal to the higher rate threshold -# (personal allowance plus basic rate limit) -# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The -# government has kept the UPL in line with the higher rate threshold, which -# Income Tax Act 2007 ss21 and 57 index by September CPI from 2031-32, and -# says the UPL will be uprated by September CPI after 2030-31 (Explanatory -# Memorandum to SI 2026/231 paras 5.1 and 5.25). That is announced policy, not -# law; keeping it equal to the higher rate threshold, round-ups included, is a -# modelling assumption consistent with it. The file has no uprating. -values: - 2015-04-06: - value: 42_385 - metadata: - reference: - - title: The Social Security (Contributions) (Re-rating and National Insurance Funds Payments) Order 2015 art 3(b) - href: https://www.legislation.gov.uk/uksi/2015/588/article/3/made - 2016-04-06: - value: 43_000 - metadata: - reference: - - title: The Social Security (Contributions) (Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2016 reg 2 - href: https://www.legislation.gov.uk/uksi/2016/343/regulation/2/made - 2017-04-06: - value: 45_000 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2017 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2017/415/regulation/5/made - 2018-04-06: - value: 46_350 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2018 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2018/337/regulation/5/made - 2019-04-06: - value: 50_000 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2019 reg 5(b) - href: https://www.legislation.gov.uk/uksi/2019/262/regulation/5/made - - title: Social Security Contributions and Benefits Act 1992 s 15(3) (as at 6 April 2020, unchanged for 2020-21) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15/2020-04-06 - 2021-04-06: - value: 50_270 - metadata: - reference: - - title: The Social Security (Contributions) (Rates, Limits and Thresholds Amendments and National Insurance Funds Payments) Regulations 2021 reg 5(2) - href: https://www.legislation.gov.uk/uksi/2021/157/regulation/5/made - 2026-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - 2027-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: "HMRC: Income Tax Personal Allowance and the basic rate limit, and certain National Insurance contributions thresholds from 6 April 2026 to 5 April 2028 (Autumn Statement 2022)" - href: https://www.gov.uk/government/publications/the-personal-allowance-and-basic-rate-limit-for-income-tax-and-certain-national-insurance-contributions-nics-thresholds-from-6-april-2026-to-5-apr/income-tax-personal-allowance-and-the-basic-rate-limit-and-certain-national-insurance-contributions-thresholds-from-6-april-2026-to-5-april-2028 - 2028-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2029-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf - 2030-04-06: - value: 50_270 - metadata: - reference: - - title: Social Security Contributions and Benefits Act 1992 s.15(3)(b) - href: https://www.legislation.gov.uk/ukpga/1992/4/section/15 - - title: Budget 2025 (HC 1492) para 4.111 - href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 - - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" - href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt - - title: Explanatory Memorandum to SI 2026/231 para 5.25 - href: https://www.legislation.gov.uk/uksi/2026/231/pdfs/uksiem_20260231_en_001.pdf -metadata: - label: NI upper profits limit - unit: currency-GBP - period: year - reference: - - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml index e07522ab16..995f53048a 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/rate.yaml @@ -2,28 +2,36 @@ description: Stamp Duty Land Tax progressive rates for residential property tran brackets: - rate: 2016-09-15: 0.03 + 2024-10-31: 0.05 threshold: 2016-09-15: 0 - rate: 2016-09-15: 0.05 2020-07-08: null 2021-03-31: 0.05 + 2022-09-23: null + 2025-04-01: 0.07 threshold: 2016-09-15: 125_000 2020-07-08: null 2021-03-31: 125_000 + 2022-09-23: null + 2025-04-01: 125_000 - rate: 2016-09-15: 0.08 + 2024-10-31: 0.10 threshold: 2016-09-15: 250_000 2020-07-08: 500_000 2021-03-31: 250_000 - rate: 2016-09-15: 0.13 + 2024-10-31: 0.15 threshold: 2016-09-15: 925_000 - rate: 2016-09-15: 0.15 + 2024-10-31: 0.17 threshold: 2016-09-15: 1_500_000 metadata: @@ -35,3 +43,9 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted + - title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(3) + href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + - title: Finance Act 2025 s. 50 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/50/enacted + - title: Finance Act 2025 s. 51 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/51/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml index e525df8d8c..1179796e47 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/max.yaml @@ -1,9 +1,13 @@ description: Maximum value for a first-property purchase to receive the Stamp Duty relief for first-time buyers. Increasing this value will underestimate the number of claims for FTBR, as the model will not add new claims. values: 2018-03-15: 500_000 + 2022-09-23: 625_000 + 2025-04-01: 500_000 metadata: unit: currency-GBP label: Stamp Duty first home value limit reference: - title: Finance Act 2003 schedule 6ZA(1)(3) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 + - title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(5) + href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml index 920d1788c0..8033317913 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/first/rate.yaml @@ -7,6 +7,8 @@ brackets: 2018-03-15: 0.05 threshold: 2018-03-15: 300000 + 2022-09-23: 425000 + 2025-04-01: 300000 description: Stamp Duty Land Tax progressive rates for residential property transfers. metadata: economy: false @@ -16,4 +18,6 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 title: Finance Act 2003 schedule 6ZA(4) + - href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1(5) threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml index 8b790e52a6..f285c30842 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/main/subsequent.yaml @@ -8,12 +8,16 @@ brackets: 2015-02-12: 0.02 2020-07-08: null 2021-03-31: 0.02 + 2022-09-23: null + 2025-04-01: 0.02 threshold: 2003-07-10: 60000 2005-04-07: 120000 2006-07-19: 125000 2020-07-08: null 2021-03-31: 125000 + 2022-09-23: null + 2025-04-01: 125000 - rate: 2003-07-10: 0.03 2015-02-12: 0.05 @@ -50,4 +54,6 @@ metadata: title: Finance Act 2003 s. 55 - href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted title: Stamp Duty Land Tax (Temporary Relief) Act 2020 + - href: https://www.legislation.gov.uk/ukpga/2023/2/section/1/enacted + title: Stamp Duty Land Tax (Temporary Relief) Act 2023 s. 1 threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml new file mode 100644 index 0000000000..ece88c9e18 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_d/amounts.yaml @@ -0,0 +1,1066 @@ +description: Band D council tax amount set by each local authority, before any discounts, exemptions or Council Tax Reduction. For England and Wales this is the area figure for the billing authority, so it includes the county, police, fire, combined authority and parish or community council precepts as well as the billing authority's own charge; for Scotland it is the council's Band D rate, which excludes the separately billed water and waste water charges. +metadata: + economy: false + propagate_metadata_to_children: true + unit: currency-GBP + period: year + label: Local authority Band D council tax amount + reference: + - title: "England 2026-27: Council tax levels set by local authorities in England, Table 10 local authority level data, sheet Data_Billing, line 17 (average Band D 2 adult equivalent council tax for the area of the billing authority, including both local and major precepts)" + href: https://assets.publishing.service.gov.uk/media/6a02eeeccd2e0e8b5b20b449/Table_10_2026-27.ods + - title: "England 2025-26: Council tax levels set by local authorities in England, Table 10 local authority level data (revised), sheet Data_Billing, line 17 (same area basis)" + href: https://assets.publishing.service.gov.uk/media/6a02f143e71c4cdf4026ba48/Table_10_2025-26.ods + - title: "Wales 2026-27 and 2025-26: Council tax levels April 2026 to March 2027, associated data tables, Table 1 column 'Overall average band D' (county council, community council and police authority elements combined); Table 2 carries the 2025-26 figure" + href: https://gov.wales/sites/default/files/statistics-and-research/2026-03/council-tax-levels-april-2026-march-2027-153478.xlsx + - title: "Scotland 2026-27 and 2025-26: Band D council tax 1996-97 to 2026-27, Council Tax datasets (excludes water and waste water charges)" + href: https://www.gov.scot/binaries/content/documents/govscot/publications/statistics/2019/04/council-tax-datasets/documents/band-d/band-d-council-tax-1996-97-to-2019-20-data/band-d-council-tax-1996-97-to-2019-20-data/govscot%3Adocument/CTAS%2B2026%2B-%2BCouncil%2BTax%2BAssumptions%2B-%2BBand%2BD%2BCouncil%2BTax%2Brates%2Bby%2Byear%2B-%2B1996-97%2Bto%2B2026-27.xlsx +ABERDEEN_CITY: + 2025-01-01: 1636.27 + 2026-01-01: 1747.54 +ABERDEENSHIRE: + 2025-01-01: 1532.76 + 2026-01-01: 1686.04 +ADUR: + 2025-01-01: 2432.50 + 2026-01-01: 2548.28 +AMBER_VALLEY: + 2025-01-01: 2297.98 + 2026-01-01: 2407.69 +ANGUS: + 2025-01-01: 1461.52 + 2026-01-01: 1598.65 +ARGYLL_AND_BUTE: + 2025-01-01: 1625.64 + 2026-01-01: 1783.33 +ARUN: + 2025-01-01: 2371.09 + 2026-01-01: 2487.24 +ASHFIELD: + 2025-01-01: 2506.63 + 2026-01-01: 2608.76 +ASHFORD: + 2025-01-01: 2311.19 + 2026-01-01: 2410.16 +BABERGH: + 2025-01-01: 2233.16 + 2026-01-01: 2341.55 +BARKING_AND_DAGENHAM: + 2025-01-01: 2098.14 + 2026-01-01: 2198.51 +BARNET: + 2025-01-01: 2035.52 + 2026-01-01: 2132.60 +BARNSLEY: + 2025-01-01: 2241.35 + 2026-01-01: 2325.96 +BASILDON: + 2025-01-01: 2244.63 + 2026-01-01: 2327.24 +BASINGSTOKE_AND_DEANE: + 2025-01-01: 2147.87 + 2026-01-01: 2255.21 +BASSETLAW: + 2025-01-01: 2539.53 + 2026-01-01: 2644.95 +BATH_AND_NORTH_EAST_SOMERSET: + 2025-01-01: 2265.80 + 2026-01-01: 2383.42 +BEDFORD: + 2025-01-01: 2350.94 + 2026-01-01: 2471.74 +BEXLEY: + 2025-01-01: 2258.03 + 2026-01-01: 2366.36 +BIRMINGHAM: + 2025-01-01: 2245.41 + 2026-01-01: 2362.90 +BLABY: + 2025-01-01: 2406.77 + 2026-01-01: 2490.22 +BLACKBURN_WITH_DARWEN: + 2025-01-01: 2337.17 + 2026-01-01: 2455.27 +BLACKPOOL: + 2025-01-01: 2392.21 + 2026-01-01: 2513.22 +BLAENAU_GWENT: + 2025-01-01: 2428.43 + 2026-01-01: 2531.26 +BOLSOVER: + 2025-01-01: 2447.51 + 2026-01-01: 2559.41 +BOLTON: + 2025-01-01: 2266.53 + 2026-01-01: 2399.74 +BOSTON: + 2025-01-01: 2236.62 + 2026-01-01: 2308.73 +BOURNEMOUTH_CHRISTCHURCH_AND_POOLE: + 2025-01-01: 2264.78 + 2026-01-01: 2435.58 +BRACKNELL_FOREST: + 2025-01-01: 2155.21 + 2026-01-01: 2264.80 +BRADFORD: + 2025-01-01: 2246.15 + 2026-01-01: 2360.73 +BRAINTREE: + 2025-01-01: 2194.67 + 2026-01-01: 2280.34 +BRECKLAND: + 2025-01-01: 2328.59 + 2026-01-01: 2443.67 +BRENT: + 2025-01-01: 2133.15 + 2026-01-01: 2235.27 +BRENTWOOD: + 2025-01-01: 2166.72 + 2026-01-01: 2258.14 +BRIDGEND: + 2025-01-01: 2357.63 + 2026-01-01: 2477.99 +BRIGHTON_AND_HOVE: + 2025-01-01: 2456.78 + 2026-01-01: 2580.54 +BRISTOL_CITY_OF: + 2025-01-01: 2583.89 + 2026-01-01: 2713.68 +BROADLAND: + 2025-01-01: 2327.99 + 2026-01-01: 2437.78 +BROMLEY: + 2025-01-01: 2042.46 + 2026-01-01: 2140.04 +BROMSGROVE: + 2025-01-01: 2302.72 + 2026-01-01: 2478.65 +BROXBOURNE: + 2025-01-01: 2198.11 + 2026-01-01: 2306.43 +BROXTOWE: + 2025-01-01: 2516.20 + 2026-01-01: 2618.21 +BUCKINGHAMSHIRE: + 2025-01-01: 2401.53 + 2026-01-01: 2526.58 +BURNLEY: + 2025-01-01: 2455.83 + 2026-01-01: 2549.42 +BURY: + 2025-01-01: 2414.58 + 2026-01-01: 2555.15 +CAERPHILLY: + 2025-01-01: 1956.20 + 2026-01-01: 2082.88 +CALDERDALE: + 2025-01-01: 2302.33 + 2026-01-01: 2420.15 +CAMBRIDGE: + 2025-01-01: 2355.41 + 2026-01-01: 2467.02 +CAMDEN: + 2025-01-01: 2106.69 + 2026-01-01: 2207.55 +CANNOCK_CHASE: + 2025-01-01: 2282.30 + 2026-01-01: 2375.83 +CANTERBURY: + 2025-01-01: 2321.73 + 2026-01-01: 2419.33 +CARDIFF: + 2025-01-01: 1926.18 + 2026-01-01: 2013.18 +CARMARTHENSHIRE: + 2025-01-01: 2232.06 + 2026-01-01: 2351.20 +CASTLE_POINT: + 2025-01-01: 2227.21 + 2026-01-01: 2318.63 +CENTRAL_BEDFORDSHIRE: + 2025-01-01: 2394.14 + 2026-01-01: 2516.16 +CEREDIGION: + 2025-01-01: 2296.09 + 2026-01-01: 2419.45 +CHARNWOOD: + 2025-01-01: 2327.50 + 2026-01-01: 2405.64 +CHELMSFORD: + 2025-01-01: 2206.97 + 2026-01-01: 2300.18 +CHELTENHAM: + 2025-01-01: 2258.28 + 2026-01-01: 2369.52 +CHERWELL: + 2025-01-01: 2463.87 + 2026-01-01: 2582.96 +CHESHIRE_EAST: + 2025-01-01: 2332.61 + 2026-01-01: 2454.87 +CHESHIRE_WEST_AND_CHESTER: + 2025-01-01: 2392.03 + 2026-01-01: 2517.33 +CHESTERFIELD: + 2025-01-01: 2233.12 + 2026-01-01: 2339.49 +CHICHESTER: + 2025-01-01: 2346.40 + 2026-01-01: 2469.68 +CHORLEY: + 2025-01-01: 2342.85 + 2026-01-01: 2430.23 +CITY_OF_EDINBURGH: + 2025-01-01: 1563.51 + 2026-01-01: 1626.05 +CITY_OF_LONDON: + 2025-01-01: 1274.07 + 2026-01-01: 1329.56 +CLACKMANNANSHIRE: + 2025-01-01: 1594.38 + 2026-01-01: 1683.67 +COLCHESTER: + 2025-01-01: 2192.08 + 2026-01-01: 2282.85 +CONWY: + 2025-01-01: 2320.83 + 2026-01-01: 2472.82 +CORNWALL: + 2025-01-01: 2459.92 + 2026-01-01: 2590.93 +COTSWOLD: + 2025-01-01: 2276.70 + 2026-01-01: 2391.65 +COUNTY_DURHAM: + 2025-01-01: 2551.17 + 2026-01-01: 2622.15 +COVENTRY: + 2025-01-01: 2413.68 + 2026-01-01: 2516.72 +CRAWLEY: + 2025-01-01: 2306.49 + 2026-01-01: 2418.46 +CROYDON: + 2025-01-01: 2480.48 + 2026-01-01: 2599.91 +CUMBERLAND: + 2025-01-01: 2393.90 + 2026-01-01: 2511.32 +DACORUM: + 2025-01-01: 2294.23 + 2026-01-01: 2408.51 +DARLINGTON: + 2025-01-01: 2371.60 + 2026-01-01: 2493.83 +DARTFORD: + 2025-01-01: 2280.55 + 2026-01-01: 2375.15 +DENBIGHSHIRE: + 2025-01-01: 2217.08 + 2026-01-01: 2339.03 +DERBY: + 2025-01-01: 2195.75 + 2026-01-01: 2306.00 +DERBYSHIRE_DALES: + 2025-01-01: 2332.42 + 2026-01-01: 2444.70 +DONCASTER: + 2025-01-01: 2110.50 + 2026-01-01: 2167.75 +DORSET: + 2025-01-01: 2630.30 + 2026-01-01: 2765.02 +DOVER: + 2025-01-01: 2364.45 + 2026-01-01: 2461.74 +DUDLEY: + 2025-01-01: 2038.57 + 2026-01-01: 2144.84 +DUMFRIES_AND_GALLOWAY: + 2025-01-01: 1454.98 + 2026-01-01: 1578.65 +DUNDEE_CITY: + 2025-01-01: 1605.34 + 2026-01-01: 1729.69 +EALING: + 2025-01-01: 2041.02 + 2026-01-01: 2138.53 +EAST_AYRSHIRE: + 2025-01-01: 1606.44 + 2026-01-01: 1717.28 +EAST_CAMBRIDGESHIRE: + 2025-01-01: 2374.13 + 2026-01-01: 2485.18 +EAST_DEVON: + 2025-01-01: 2461.40 + 2026-01-01: 2595.46 +EAST_DUNBARTONSHIRE: + 2025-01-01: 1599.70 + 2026-01-01: 1751.67 +EAST_HAMPSHIRE: + 2025-01-01: 2231.64 + 2026-01-01: 2343.92 +EAST_HERTFORDSHIRE: + 2025-01-01: 2339.63 + 2026-01-01: 2454.78 +EAST_LINDSEY: + 2025-01-01: 2201.57 + 2026-01-01: 2275.88 +EAST_LOTHIAN: + 2025-01-01: 1579.18 + 2026-01-01: 1697.62 +EAST_RENFREWSHIRE: + 2025-01-01: 1528.44 + 2026-01-01: 1620.15 +EAST_RIDING_OF_YORKSHIRE: + 2025-01-01: 2343.55 + 2026-01-01: 2466.08 +EAST_STAFFORDSHIRE: + 2025-01-01: 2257.48 + 2026-01-01: 2351.34 +EAST_SUFFOLK: + 2025-01-01: 2222.36 + 2026-01-01: 2334.80 +EASTBOURNE: + 2025-01-01: 2532.49 + 2026-01-01: 2654.28 +EASTLEIGH: + 2025-01-01: 2227.68 + 2026-01-01: 2342.21 +ELMBRIDGE: + 2025-01-01: 2442.92 + 2026-01-01: 2557.75 +ENFIELD: + 2025-01-01: 2164.02 + 2026-01-01: 2267.67 +EPPING_FOREST: + 2025-01-01: 2186.69 + 2026-01-01: 2277.21 +EPSOM_AND_EWELL: + 2025-01-01: 2416.84 + 2026-01-01: 2530.84 +EREWASH: + 2025-01-01: 2260.06 + 2026-01-01: 2360.64 +EXETER: + 2025-01-01: 2379.90 + 2026-01-01: 2495.36 +FALKIRK: + 2025-01-01: 1576.77 + 2026-01-01: 1715.09 +FAREHAM: + 2025-01-01: 2164.55 + 2026-01-01: 2270.55 +FENLAND: + 2025-01-01: 2432.81 + 2026-01-01: 2537.74 +FIFE: + 2025-01-01: 1498.76 + 2026-01-01: 1573.70 +FLINTSHIRE: + 2025-01-01: 2246.47 + 2026-01-01: 2376.17 +FOLKESTONE_AND_HYTHE: + 2025-01-01: 2437.35 + 2026-01-01: 2538.75 +FOREST_OF_DEAN: + 2025-01-01: 2326.88 + 2026-01-01: 2443.42 +FYLDE: + 2025-01-01: 2387.78 + 2026-01-01: 2483.94 +GATESHEAD: + 2025-01-01: 2578.46 + 2026-01-01: 2715.81 +GEDLING: + 2025-01-01: 2507.09 + 2026-01-01: 2609.55 +GLASGOW_CITY: + 2025-01-01: 1611.00 + 2026-01-01: 1706.00 +GLOUCESTER: + 2025-01-01: 2246.16 + 2026-01-01: 2355.84 +GOSPORT: + 2025-01-01: 2236.14 + 2026-01-01: 2344.29 +GRAVESHAM: + 2025-01-01: 2313.06 + 2026-01-01: 2408.24 +GREAT_YARMOUTH: + 2025-01-01: 2305.13 + 2026-01-01: 2414.41 +GREENWICH: + 2025-01-01: 2011.81 + 2026-01-01: 2107.86 +GUILDFORD: + 2025-01-01: 2428.89 + 2026-01-01: 2547.30 +GWYNEDD: + 2025-01-01: 2340.22 + 2026-01-01: 2468.77 +HACKNEY: + 2025-01-01: 1966.51 + 2026-01-01: 2060.30 +HALTON: + 2025-01-01: 2249.34 + 2026-01-01: 2366.61 +HAMMERSMITH_AND_FULHAM: + 2025-01-01: 1451.42 + 2026-01-01: 1519.51 +HARBOROUGH: + 2025-01-01: 2324.96 + 2026-01-01: 2389.87 +HARINGEY: + 2025-01-01: 2207.94 + 2026-01-01: 2313.78 +HARLOW: + 2025-01-01: 2216.43 + 2026-01-01: 2298.69 +HARROW: + 2025-01-01: 2395.86 + 2026-01-01: 2511.07 +HART: + 2025-01-01: 2285.15 + 2026-01-01: 2400.02 +HARTLEPOOL: + 2025-01-01: 2498.53 + 2026-01-01: 2560.20 +HASTINGS: + 2025-01-01: 2554.14 + 2026-01-01: 2676.58 +HAVANT: + 2025-01-01: 2212.89 + 2026-01-01: 2320.28 +HAVERING: + 2025-01-01: 2313.55 + 2026-01-01: 2424.66 +HEREFORDSHIRE_COUNTY_OF: + 2025-01-01: 2449.17 + 2026-01-01: 2574.37 +HERTSMERE: + 2025-01-01: 2289.22 + 2026-01-01: 2405.83 +HIGH_PEAK: + 2025-01-01: 2275.52 + 2026-01-01: 2384.66 +HIGHLAND: + 2025-01-01: 1527.09 + 2026-01-01: 1633.99 +HILLINGDON: + 2025-01-01: 1952.38 + 2026-01-01: 2045.46 +HINCKLEY_AND_BOSWORTH: + 2025-01-01: 2304.14 + 2026-01-01: 2382.62 +HORSHAM: + 2025-01-01: 2321.54 + 2026-01-01: 2441.01 +HOUNSLOW: + 2025-01-01: 2085.82 + 2026-01-01: 2185.56 +HUNTINGDONSHIRE: + 2025-01-01: 2439.67 + 2026-01-01: 2556.40 +HYNDBURN: + 2025-01-01: 2380.02 + 2026-01-01: 2465.98 +INVERCLYDE: + 2025-01-01: 1551.30 + 2026-01-01: 1673.85 +IPSWICH: + 2025-01-01: 2358.54 + 2026-01-01: 2468.25 +ISLE_OF_ANGLESEY: + 2025-01-01: 2143.94 + 2026-01-01: 2260.73 +ISLE_OF_WIGHT: + 2025-01-01: 2493.36 + 2026-01-01: 2625.79 +ISLES_OF_SCILLY: + 2025-01-01: 1950.03 + 2026-01-01: 2047.96 +ISLINGTON: + 2025-01-01: 2012.10 + 2026-01-01: 2108.15 +KENSINGTON_AND_CHELSEA: + 2025-01-01: 1591.59 + 2026-01-01: 1666.65 +KINGS_LYNN_AND_WEST_NORFOLK: + 2025-01-01: 2318.63 + 2026-01-01: 2430.13 +KINGSTON_UPON_HULL_CITY_OF: + 2025-01-01: 2182.52 + 2026-01-01: 2295.04 +KINGSTON_UPON_THAMES: + 2025-01-01: 2489.34 + 2026-01-01: 2609.20 +KIRKLEES: + 2025-01-01: 2321.76 + 2026-01-01: 2441.07 +KNOWSLEY: + 2025-01-01: 2371.28 + 2026-01-01: 2488.25 +LAMBETH: + 2025-01-01: 1953.95 + 2026-01-01: 2047.11 +LANCASTER: + 2025-01-01: 2407.50 + 2026-01-01: 2503.19 +LEEDS: + 2025-01-01: 2172.39 + 2026-01-01: 2283.73 +LEICESTER: + 2025-01-01: 2407.72 + 2026-01-01: 2528.75 +LEWES: + 2025-01-01: 2627.39 + 2026-01-01: 2756.17 +LEWISHAM: + 2025-01-01: 2135.13 + 2026-01-01: 2237.33 +LICHFIELD: + 2025-01-01: 2259.21 + 2026-01-01: 2351.90 +LINCOLN: + 2025-01-01: 2260.98 + 2026-01-01: 2323.08 +LIVERPOOL: + 2025-01-01: 2546.45 + 2026-01-01: 2673.59 +LUTON: + 2025-01-01: 2320.43 + 2026-01-01: 2439.93 +MAIDSTONE: + 2025-01-01: 2403.78 + 2026-01-01: 2502.59 +MALDON: + 2025-01-01: 2232.83 + 2026-01-01: 2328.37 +MALVERN_HILLS: + 2025-01-01: 2280.82 + 2026-01-01: 2450.92 +MANCHESTER: + 2025-01-01: 2183.03 + 2026-01-01: 2312.04 +MANSFIELD: + 2025-01-01: 2498.40 + 2026-01-01: 2600.38 +MEDWAY: + 2025-01-01: 2215.38 + 2026-01-01: 2328.55 +MELTON: + 2025-01-01: 2350.68 + 2026-01-01: 2429.67 +MERTHYR_TYDFIL: + 2025-01-01: 2464.01 + 2026-01-01: 2593.60 +MERTON: + 2025-01-01: 2094.43 + 2026-01-01: 2146.76 +MID_DEVON: + 2025-01-01: 2521.17 + 2026-01-01: 2656.41 +MID_SUFFOLK: + 2025-01-01: 2208.43 + 2026-01-01: 2316.88 +MID_SUSSEX: + 2025-01-01: 2356.49 + 2026-01-01: 2474.26 +MIDDLESBROUGH: + 2025-01-01: 2487.65 + 2026-01-01: 2549.16 +MIDLOTHIAN: + 2025-01-01: 1666.20 + 2026-01-01: 1816.16 +MILTON_KEYNES: + 2025-01-01: 2257.98 + 2026-01-01: 2372.21 +MOLE_VALLEY: + 2025-01-01: 2405.75 + 2026-01-01: 2519.86 +MONMOUTHSHIRE: + 2025-01-01: 2277.19 + 2026-01-01: 2416.50 +MORAY: + 2025-01-01: 1573.76 + 2026-01-01: 1731.14 +NA_H_EILEANAN_SIAR: + 2025-01-01: 1387.56 + 2026-01-01: 1505.50 +NEATH_PORT_TALBOT: + 2025-01-01: 2440.78 + 2026-01-01: 2541.39 +NEW_FOREST: + 2025-01-01: 2303.37 + 2026-01-01: 2419.76 +NEWARK_AND_SHERWOOD: + 2025-01-01: 2582.22 + 2026-01-01: 2682.10 +NEWCASTLE_UNDER_LYME: + 2025-01-01: 2244.85 + 2026-01-01: 2334.25 +NEWCASTLE_UPON_TYNE: + 2025-01-01: 2413.15 + 2026-01-01: 2542.19 +NEWHAM: + 2025-01-01: 1855.96 + 2026-01-01: 1944.23 +NEWPORT: + 2025-01-01: 1983.97 + 2026-01-01: 2088.48 +NORTH_AYRSHIRE: + 2025-01-01: 1553.77 + 2026-01-01: 1685.84 +NORTH_DEVON: + 2025-01-01: 2514.66 + 2026-01-01: 2642.21 +NORTH_EAST_DERBYSHIRE: + 2025-01-01: 2354.93 + 2026-01-01: 2466.57 +NORTH_EAST_LINCOLNSHIRE: + 2025-01-01: 2371.01 + 2026-01-01: 2483.85 +NORTH_HERTFORDSHIRE: + 2025-01-01: 2332.61 + 2026-01-01: 2448.52 +NORTH_KESTEVEN: + 2025-01-01: 2261.06 + 2026-01-01: 2337.81 +NORTH_LANARKSHIRE: + 2025-01-01: 1452.86 + 2026-01-01: 1554.56 +NORTH_LINCOLNSHIRE: + 2025-01-01: 2238.09 + 2026-01-01: 2348.46 +NORTH_NORFOLK: + 2025-01-01: 2341.13 + 2026-01-01: 2459.93 +NORTH_NORTHAMPTONSHIRE: + 2025-01-01: 2301.93 + 2026-01-01: 2423.96 +NORTH_SOMERSET: + 2025-01-01: 2294.94 + 2026-01-01: 2491.22 +NORTH_TYNESIDE: + 2025-01-01: 2336.45 + 2026-01-01: 2461.77 +NORTH_WARWICKSHIRE: + 2025-01-01: 2431.78 + 2026-01-01: 2532.10 +NORTH_WEST_LEICESTERSHIRE: + 2025-01-01: 2338.10 + 2026-01-01: 2418.20 +NORTH_YORKSHIRE: + 2025-01-01: 2418.26 + 2026-01-01: 2544.34 +NORTHUMBERLAND: + 2025-01-01: 2464.63 + 2026-01-01: 2596.94 +NORWICH: + 2025-01-01: 2391.59 + 2026-01-01: 2503.44 +NOTTINGHAM: + 2025-01-01: 2656.19 + 2026-01-01: 2755.39 +NUNEATON_AND_BEDWORTH: + 2025-01-01: 2398.13 + 2026-01-01: 2502.16 +OADBY_AND_WIGSTON: + 2025-01-01: 2330.02 + 2026-01-01: 2406.85 +OLDHAM: + 2025-01-01: 2459.40 + 2026-01-01: 2602.23 +ORKNEY_ISLANDS: + 2025-01-01: 1574.60 + 2026-01-01: 1669.07 +OXFORD: + 2025-01-01: 2557.13 + 2026-01-01: 2678.40 +PEMBROKESHIRE: + 2025-01-01: 2059.82 + 2026-01-01: 2166.15 +PENDLE: + 2025-01-01: 2535.19 + 2026-01-01: 2640.18 +PERTH_AND_KINROSS: + 2025-01-01: 1537.04 + 2026-01-01: 1673.84 +PETERBOROUGH: + 2025-01-01: 2185.72 + 2026-01-01: 2293.47 +PLYMOUTH: + 2025-01-01: 2325.42 + 2026-01-01: 2441.85 +PORTSMOUTH: + 2025-01-01: 2180.92 + 2026-01-01: 2291.71 +POWYS: + 2025-01-01: 2234.44 + 2026-01-01: 2351.72 +PRESTON: + 2025-01-01: 2477.85 + 2026-01-01: 2575.74 +READING: + 2025-01-01: 2487.11 + 2026-01-01: 2612.77 +REDBRIDGE: + 2025-01-01: 2189.67 + 2026-01-01: 2294.58 +REDCAR_AND_CLEVELAND: + 2025-01-01: 2424.47 + 2026-01-01: 2544.48 +REDDITCH: + 2025-01-01: 2289.33 + 2026-01-01: 2462.95 +REIGATE_AND_BANSTEAD: + 2025-01-01: 2459.20 + 2026-01-01: 2566.61 +RENFREWSHIRE: + 2025-01-01: 1572.61 + 2026-01-01: 1690.56 +RHONDDA_CYNON_TAF: + 2025-01-01: 2190.10 + 2026-01-01: 2289.43 +RIBBLE_VALLEY: + 2025-01-01: 2299.58 + 2026-01-01: 2386.50 +RICHMOND_UPON_THAMES: + 2025-01-01: 2372.07 + 2026-01-01: 2486.10 +ROCHDALE: + 2025-01-01: 2458.09 + 2026-01-01: 2600.83 +ROCHFORD: + 2025-01-01: 2268.00 + 2026-01-01: 2363.48 +ROSSENDALE: + 2025-01-01: 2424.37 + 2026-01-01: 2519.97 +ROTHER: + 2025-01-01: 2561.29 + 2026-01-01: 2700.95 +ROTHERHAM: + 2025-01-01: 2283.26 + 2026-01-01: 2381.53 +RUGBY: + 2025-01-01: 2378.04 + 2026-01-01: 2483.05 +RUNNYMEDE: + 2025-01-01: 2380.06 + 2026-01-01: 2492.99 +RUSHCLIFFE: + 2025-01-01: 2531.50 + 2026-01-01: 2643.27 +RUSHMOOR: + 2025-01-01: 2212.82 + 2026-01-01: 2320.28 +RUTLAND: + 2025-01-01: 2671.01 + 2026-01-01: 2737.58 +SALFORD: + 2025-01-01: 2452.02 + 2026-01-01: 2594.45 +SANDWELL: + 2025-01-01: 2133.45 + 2026-01-01: 2244.46 +SCOTTISH_BORDERS: + 2025-01-01: 1491.72 + 2026-01-01: 1618.52 +SEFTON: + 2025-01-01: 2460.92 + 2026-01-01: 2583.22 +SEVENOAKS: + 2025-01-01: 2419.44 + 2026-01-01: 2522.36 +SHEFFIELD: + 2025-01-01: 2388.76 + 2026-01-01: 2510.16 +SHETLAND_ISLANDS: + 2025-01-01: 1386.67 + 2026-01-01: 1487.90 +SHROPSHIRE: + 2025-01-01: 2318.80 + 2026-01-01: 2528.18 +SLOUGH: + 2025-01-01: 2298.55 + 2026-01-01: 2414.44 +SOLIHULL: + 2025-01-01: 2087.81 + 2026-01-01: 2197.26 +SOMERSET: + 2025-01-01: 2438.62 + 2026-01-01: 2560.59 +SOUTH_AYRSHIRE: + 2025-01-01: 1569.41 + 2026-01-01: 1694.96 +SOUTH_CAMBRIDGESHIRE: + 2025-01-01: 2414.52 + 2026-01-01: 2536.25 +SOUTH_DERBYSHIRE: + 2025-01-01: 2231.86 + 2026-01-01: 2339.61 +SOUTH_GLOUCESTERSHIRE: + 2025-01-01: 2427.90 + 2026-01-01: 2550.59 +SOUTH_HAMS: + 2025-01-01: 2491.75 + 2026-01-01: 2615.22 +SOUTH_HOLLAND: + 2025-01-01: 2205.31 + 2026-01-01: 2278.20 +SOUTH_KESTEVEN: + 2025-01-01: 2186.95 + 2026-01-01: 2260.00 +SOUTH_LANARKSHIRE: + 2025-01-01: 1378.85 + 2026-01-01: 1468.47 +SOUTH_NORFOLK: + 2025-01-01: 2367.89 + 2026-01-01: 2482.87 +SOUTH_OXFORDSHIRE: + 2025-01-01: 2474.44 + 2026-01-01: 2597.57 +SOUTH_RIBBLE: + 2025-01-01: 2352.12 + 2026-01-01: 2442.66 +SOUTH_STAFFORDSHIRE: + 2025-01-01: 2219.66 + 2026-01-01: 2313.67 +SOUTH_TYNESIDE: + 2025-01-01: 2311.18 + 2026-01-01: 2434.43 +SOUTHAMPTON: + 2025-01-01: 2266.44 + 2026-01-01: 2381.48 +SOUTHEND_ON_SEA: + 2025-01-01: 2154.65 + 2026-01-01: 2265.17 +SOUTHWARK: + 2025-01-01: 1877.90 + 2026-01-01: 1967.26 +SPELTHORNE: + 2025-01-01: 2412.78 + 2026-01-01: 2526.44 +ST_ALBANS: + 2025-01-01: 2305.95 + 2026-01-01: 2419.22 +ST_HELENS: + 2025-01-01: 2288.53 + 2026-01-01: 2403.38 +STAFFORD: + 2025-01-01: 2210.78 + 2026-01-01: 2302.68 +STAFFORDSHIRE_MOORLANDS: + 2025-01-01: 2240.43 + 2026-01-01: 2338.21 +STEVENAGE: + 2025-01-01: 2281.28 + 2026-01-01: 2391.97 +STIRLING: + 2025-01-01: 1611.78 + 2026-01-01: 1752.87 +STOCKPORT: + 2025-01-01: 2475.31 + 2026-01-01: 2618.90 +STOCKTON_ON_TEES: + 2025-01-01: 2468.74 + 2026-01-01: 2589.83 +STOKE_ON_TRENT: + 2025-01-01: 2078.34 + 2026-01-01: 2183.12 +STRATFORD_ON_AVON: + 2025-01-01: 2379.91 + 2026-01-01: 2484.17 +STROUD: + 2025-01-01: 2369.37 + 2026-01-01: 2491.36 +SUNDERLAND: + 2025-01-01: 2092.79 + 2026-01-01: 2197.14 +SURREY_HEATH: + 2025-01-01: 2467.86 + 2026-01-01: 2585.16 +SUTTON: + 2025-01-01: 2269.72 + 2026-01-01: 2378.64 +SWALE: + 2025-01-01: 2305.72 + 2026-01-01: 2405.65 +SWANSEA: + 2025-01-01: 2142.88 + 2026-01-01: 2238.29 +SWINDON: + 2025-01-01: 2317.63 + 2026-01-01: 2438.10 +TAMESIDE: + 2025-01-01: 2311.80 + 2026-01-01: 2447.21 +TAMWORTH: + 2025-01-01: 2209.81 + 2026-01-01: 2300.76 +TANDRIDGE: + 2025-01-01: 2471.62 + 2026-01-01: 2594.85 +TEIGNBRIDGE: + 2025-01-01: 2512.83 + 2026-01-01: 2643.12 +TELFORD_AND_WREKIN: + 2025-01-01: 2146.19 + 2026-01-01: 2256.26 +TENDRING: + 2025-01-01: 2178.46 + 2026-01-01: 2269.63 +TEST_VALLEY: + 2025-01-01: 2196.53 + 2026-01-01: 2305.52 +TEWKESBURY: + 2025-01-01: 2226.55 + 2026-01-01: 2340.53 +THANET: + 2025-01-01: 2391.23 + 2026-01-01: 2490.90 +THREE_RIVERS: + 2025-01-01: 2306.47 + 2026-01-01: 2422.31 +THURROCK: + 2025-01-01: 2145.06 + 2026-01-01: 2254.68 +TONBRIDGE_AND_MALLING: + 2025-01-01: 2374.77 + 2026-01-01: 2471.83 +TORBAY: + 2025-01-01: 2350.64 + 2026-01-01: 2470.25 +TORFAEN: + 2025-01-01: 2059.50 + 2026-01-01: 2155.68 +TORRIDGE: + 2025-01-01: 2479.12 + 2026-01-01: 2601.42 +TOWER_HAMLETS: + 2025-01-01: 1754.57 + 2026-01-01: 1837.78 +TRAFFORD: + 2025-01-01: 2122.54 + 2026-01-01: 2291.70 +TUNBRIDGE_WELLS: + 2025-01-01: 2347.20 + 2026-01-01: 2446.15 +UTTLESFORD: + 2025-01-01: 2237.45 + 2026-01-01: 2333.82 +VALE_OF_GLAMORGAN: + 2025-01-01: 2093.96 + 2026-01-01: 2231.54 +VALE_OF_WHITE_HORSE: + 2025-01-01: 2456.82 + 2026-01-01: 2577.44 +WAKEFIELD: + 2025-01-01: 2185.93 + 2026-01-01: 2296.89 +WALSALL: + 2025-01-01: 2498.27 + 2026-01-01: 2627.48 +WALTHAM_FOREST: + 2025-01-01: 2277.65 + 2026-01-01: 2386.96 +WANDSWORTH: + 2025-01-01: 997.75 + 2026-01-01: 1028.21 +WARRINGTON: + 2025-01-01: 2281.40 + 2026-01-01: 2447.61 +WARWICK: + 2025-01-01: 2356.53 + 2026-01-01: 2461.55 +WATFORD: + 2025-01-01: 2343.38 + 2026-01-01: 2446.70 +WAVERLEY: + 2025-01-01: 2483.85 + 2026-01-01: 2604.87 +WEALDEN: + 2025-01-01: 2608.44 + 2026-01-01: 2728.43 +WELWYN_HATFIELD: + 2025-01-01: 2333.46 + 2026-01-01: 2452.26 +WEST_BERKSHIRE: + 2025-01-01: 2382.84 + 2026-01-01: 2505.37 +WEST_DEVON: + 2025-01-01: 2574.29 + 2026-01-01: 2705.33 +WEST_DUNBARTONSHIRE: + 2025-01-01: 1559.86 + 2026-01-01: 1681.53 +WEST_LANCASHIRE: + 2025-01-01: 2362.12 + 2026-01-01: 2456.23 +WEST_LINDSEY: + 2025-01-01: 2279.77 + 2026-01-01: 2354.25 +WEST_LOTHIAN: + 2025-01-01: 1515.45 + 2026-01-01: 1627.59 +WEST_NORTHAMPTONSHIRE: + 2025-01-01: 2362.51 + 2026-01-01: 2486.66 +WEST_OXFORDSHIRE: + 2025-01-01: 2443.88 + 2026-01-01: 2566.95 +WEST_SUFFOLK: + 2025-01-01: 2243.00 + 2026-01-01: 2351.47 +WESTMINSTER: + 2025-01-01: 1019.00 + 2026-01-01: 1049.55 +WESTMORLAND_AND_FURNESS: + 2025-01-01: 2389.00 + 2026-01-01: 2507.99 +WIGAN: + 2025-01-01: 2031.28 + 2026-01-01: 2152.68 +WILTSHIRE: + 2025-01-01: 2443.40 + 2026-01-01: 2571.78 +WINCHESTER: + 2025-01-01: 2251.22 + 2026-01-01: 2359.76 +WINDSOR_AND_MAIDENHEAD: + 2025-01-01: 1823.50 + 2026-01-01: 1952.62 +WIRRAL: + 2025-01-01: 2381.66 + 2026-01-01: 2500.59 +WOKING: + 2025-01-01: 2482.03 + 2026-01-01: 2598.04 +WOKINGHAM: + 2025-01-01: 2376.11 + 2026-01-01: 2497.70 +WOLVERHAMPTON: + 2025-01-01: 2413.98 + 2026-01-01: 2538.99 +WORCESTER: + 2025-01-01: 2233.93 + 2026-01-01: 2405.81 +WORTHING: + 2025-01-01: 2343.12 + 2026-01-01: 2456.13 +WREXHAM: + 2025-01-01: 2192.89 + 2026-01-01: 2308.88 +WYCHAVON: + 2025-01-01: 2200.98 + 2026-01-01: 2373.19 +WYRE: + 2025-01-01: 2365.73 + 2026-01-01: 2460.53 +WYRE_FOREST: + 2025-01-01: 2327.49 + 2026-01-01: 2512.47 +YORK: + 2025-01-01: 2175.05 + 2026-01-01: 2287.15 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml new file mode 100644 index 0000000000..f6b6a001b6 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/england.yaml @@ -0,0 +1,26 @@ +description: Statutory council tax band proportions for England, relative to Band D. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: England council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 5 + href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 +A: + 1993-04-01: 0.666666667 # 2/3 +B: + 1993-04-01: 0.777777778 # 7/9 +C: + 1993-04-01: 0.888888889 # 8/9 +D: + 1993-04-01: 1.000000000 # 1/1 +E: + 1993-04-01: 1.222222222 # 11/9 +F: + 1993-04-01: 1.444444444 # 13/9 +G: + 1993-04-01: 1.666666667 # 5/3 +H: + 1993-04-01: 2.000000000 # 2/1 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml new file mode 100644 index 0000000000..373236e552 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/scotland.yaml @@ -0,0 +1,32 @@ +description: Statutory council tax band proportions for Scotland, relative to Band D. Bands A to D have been unchanged since 1993; SSI 2016/368 substituted a new proportion in section 74(1) of the Local Government Finance Act 1992, raising bands E to H with effect for financial years beginning on or after 1 April 2017. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: Scotland council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 74 (proportion 6:7:8:9:11:13:15:18 as originally enacted) + href: https://www.legislation.gov.uk/ukpga/1992/14/section/74/enacted + - title: The Council Tax (Substitution of Proportion) (Scotland) Order 2016 (SSI 2016/368), article 2 (proportion 240:280:320:360:473:585:705:882 for financial years from 1 April 2017) + href: https://www.legislation.gov.uk/ssi/2016/368/article/2/made +A: + 1993-04-01: 0.666666667 # 6/9, and 240/360 from 2017 +B: + 1993-04-01: 0.777777778 # 7/9, and 280/360 from 2017 +C: + 1993-04-01: 0.888888889 # 8/9, and 320/360 from 2017 +D: + 1993-04-01: 1.000000000 # 9/9, and 360/360 from 2017 +E: + 1993-04-01: 1.222222222 # 11/9 + 2017-04-01: 1.313888889 # 473/360 +F: + 1993-04-01: 1.444444444 # 13/9 + 2017-04-01: 1.625000000 # 585/360 +G: + 1993-04-01: 1.666666667 # 15/9 + 2017-04-01: 1.958333333 # 705/360 +H: + 1993-04-01: 2.000000000 # 18/9 + 2017-04-01: 2.450000000 # 882/360 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml new file mode 100644 index 0000000000..d2a2ebc330 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax/band_ratios/wales.yaml @@ -0,0 +1,30 @@ +description: Statutory council tax band proportions for Wales, relative to Band D. Wales has nine bands (A to I) from 1 April 2005. +metadata: + economy: false + propagate_metadata_to_children: true + unit: /1 + period: year + label: Wales council tax band ratios + reference: + - title: Local Government Finance Act 1992, section 5 + href: https://www.legislation.gov.uk/ukpga/1992/14/section/5 + - title: The Council Tax (Valuation Bands) (Wales) Order 2003 + href: https://www.legislation.gov.uk/wsi/2003/3046/made +A: + 2005-04-01: 0.666666667 # 2/3 +B: + 2005-04-01: 0.777777778 # 7/9 +C: + 2005-04-01: 0.888888889 # 8/9 +D: + 2005-04-01: 1.000000000 # 1/1 +E: + 2005-04-01: 1.222222222 # 11/9 +F: + 2005-04-01: 1.444444444 # 13/9 +G: + 2005-04-01: 1.666666667 # 5/3 +H: + 2005-04-01: 2.000000000 # 2/1 +I: + 2005-04-01: 2.333333333 # 7/3 diff --git a/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py b/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py index d88f3a7616..c2108e39c5 100644 --- a/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py +++ b/policyengine_uk/parameters/gov/simulation/labour_supply_responses/aliases.py @@ -10,6 +10,7 @@ """ from policyengine_core.parameters import ParameterNode +from policyengine_uk.utils.ni_parameter_scales import canonicalize_ni_parameter_path _LEGACY_LSR_PATH = "gov.simulation.labor_supply_responses" @@ -35,7 +36,7 @@ def canonicalize_parameter_changes(changes: dict) -> dict: """ canonical = {} for path, value in changes.items(): - name = canonicalize_lsr_parameter_path(path) + name = canonicalize_ni_parameter_path(canonicalize_lsr_parameter_path(path)) if name not in canonical: canonical[name] = value elif isinstance(canonical[name], dict) and isinstance(value, dict): diff --git a/policyengine_uk/parameters/gov/simulation/pensions/README.md b/policyengine_uk/parameters/gov/simulation/pensions/README.md new file mode 100644 index 0000000000..bade491b98 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/README.md @@ -0,0 +1 @@ +# Pensions diff --git a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml new file mode 100644 index 0000000000..d28a31aa1c --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml @@ -0,0 +1,49 @@ +description: >- + Defined-benefit pension input amount per pound of contributions paid into + defined-benefit arrangements, used for the annual allowance where the input + amount itself is not supplied. +# The statutory input amount values the increase in a member's rights over +# the year (Finance Act 2004 s. 234): 16 times the annual pension, plus any +# separate lump sum, at the end of the year less the same at the start, the +# start raised by CPI (s. 235). It is not the cash paid in. For an active +# member with ten years' service in 2024-25 (opening value raised by CPI of +# 6.7%), it is about this share of pensionable pay in the four largest public +# service schemes, against employer plus member contributions: +# - NHS 2015 (1/54 accrual, revalued at CPI + 1.5%): 34%; contributions 23.7% +# plus 5.2-12.5%, a ratio of 0.94 to 1.18. +# - Teachers 2015 (1/57, CPI + 1.6%): 33%; 28.68% plus 7.4-11.7%, 0.81 to 0.90. +# - Civil Service alpha (2.32%, CPI): 37%; 28.97% plus 4.6-8.05%, 1.00 to 1.10. +# - LGPS 2014 (1/49, CPI): 33%; about 21.1% plus 5.5-12.5%, 0.97 to 1.23. +# The ratio is lower where employer rates are higher (Police 2015, 35.3%; +# Armed Forces 2015, 73.5%), and private-sector schemes, whose employer rates +# are often much higher, would push it below 1 too. Survey employer +# contributions drawn from employers' regular contribution rates leave out +# deficit repair payments. A ratio of 1, counting contributions at face value, +# is a chosen midpoint: the ten years' service and the step to exactly 1 are +# judgements. The comparison holds from 2023-24, since when these schemes +# revalue on 6 April with the same September CPI as s. 235's opening value; +# it does not hold for pension input periods before then. Data that values +# defined-benefit accrual supplies defined_benefit_pension_input_amount. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Defined-benefit pension input per pound of contributions + reference: + - title: Finance Act 2004 s. 234 (defined benefits arrangements) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/234 + - title: Finance Act 2004 s. 235 (CPI increase in the opening value) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/235 + - title: NHS Pension Scheme Regulations 2015 Sch. 9 para. 13 (1/54 accrual) + href: https://www.legislation.gov.uk/uksi/2015/94/schedule/9/paragraph/13/made + - title: NHS Pension Scheme annual report and accounts 2024-25 (23.7% employer; 5.2% to 12.5% member) + href: https://assets.publishing.service.gov.uk/media/687a5e455f0f5104b9806baa/nhs-pension-scheme-annual-report-and-accounts-2024-to-2025.pdf + - title: Teachers' Pension Scheme annual report and accounts 2024-25 (1/57 accrual, CPI + 1.6%; 28.6% employer plus 0.08% levy; 7.4% to 11.7% member) + href: https://assets.publishing.service.gov.uk/media/687610ae55c4bd0544dcae36/DfE_TPS_Annual_Report_2024-25.pdf + - title: Civil Superannuation annual account 2024-25 (alpha 2.32% accrual; 28.97% employer) + href: https://www.gov.uk/government/publications/civil-superannuation-annual-account-2024-to-2025/civil-superannuation-annual-account-2024-to-2025-html + - title: LGPS Scheme Advisory Board, 2022 valuations report (21.1% average employer rate) + href: https://joomla.lgpsboard.org/index.php/2022-valuations-report + - title: Local Government Pension Scheme Regulations 2013 reg. 23 (1/49 accrual) + href: https://www.legislation.gov.uk/uksi/2013/2356/regulation/23/made diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml new file mode 100644 index 0000000000..c57821b4c6 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -0,0 +1,33 @@ +description: >- + Pension input amount in each previous tax year as a share of this year's, + after average earnings growth, used to estimate unused annual allowance + carried forward where data has no pension history. The estimate looks back + five years: the three whose unused allowance is carried forward and the two + before them, whose unused allowance an excess in those three uses first. +# Finance Act 2004 s. 228A carries forward the annual allowance that the +# pension input amount left unused in each of the three previous tax years in +# which the individual was a member of a registered pension scheme, earliest +# year first. Surveys record this year's contributions only. With a ratio of +# 1, each previous year's input is this year's deflated by average earnings +# growth, as for a member saving the same share of pay each year, and the +# income tested for that year's taper is deflated the same way. A ratio of 0 +# assumes no input in those years, the most allowance there could be to +# carry forward. HMRC publishes no count of carry-forward use: Self Assessment +# reports only the excess over the allowance once it is applied. So 1 is a +# convention, not an estimate from statistics. With smooth earnings growth it +# leaves almost nothing to carry forward for anyone over this year's +# allowance, while real excesses are often lumpy (a defined-benefit promotion, +# a one-off contribution), so it probably understates carry forward. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Pension input in each previous year relative to this year, for carry forward + reference: + - title: Finance Act 2004 s. 228A + href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A + - title: HMRC Pensions Tax Manual PTM055100 (carry forward) + href: https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + - title: HMRC, HS345 Pension savings tax charges (2025) + href: https://www.gov.uk/government/publications/pensions-tax-charges-on-any-excess-over-the-lifetime-allowance-annual-allowance-special-annual-allowance-and-on-unauthorised-payments-hs345-self/hs345-pension-savings-tax-charges-2025 diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml new file mode 100644 index 0000000000..7feed18873 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml @@ -0,0 +1,30 @@ +description: Weekly net earnings limit for Carer Support Payment, after the favorable fractional-penny rule. +metadata: + economy: false + period: week + unit: currency-GBP + label: Carer Support Payment earnings limit + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulation 14 + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14 +values: + 2023-11-19: + value: 139 + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulations 1 and 14 as made + href: https://www.legislation.gov.uk/ssi/2023/302/made + 2024-04-07: + value: 151 + reference: + - title: Regulation 14 as amended from 7 April 2024 by SSI 2024/105, regulation 7(2) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14/2024-04-07 + 2025-04-06: + value: 196 + reference: + - title: Regulation 14 as amended from 6 April 2025 by SSI 2025/100, regulation 13(2) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/14/2025-04-06 + 2026-04-05: + value: 204 + reference: + - title: The Social Security (Up-rating) (Miscellaneous Amendments) (Scotland) Regulations 2026, regulation 12(2) + href: https://www.legislation.gov.uk/ssi/2026/170/regulation/12/made diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml new file mode 100644 index 0000000000..9b95aa9e31 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/earnings_rounding_precision.yaml @@ -0,0 +1,11 @@ +description: Penny precision for the favorable fractional-penny calculation of Carer Support Payment weekly earnings. +values: + 2023-11-19: 0.01 +metadata: + economy: false + period: week + unit: currency-GBP + label: Carer Support Payment earnings rounding precision + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, schedule 2 paragraph 4 + href: https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4 diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml new file mode 100644 index 0000000000..de802d9190 --- /dev/null +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/min_age.yaml @@ -0,0 +1,10 @@ +description: Minimum age for computed Carer Support Payment entitlement. +values: + 2023-11-19: 16 +metadata: + unit: year + period: year + label: Carer Support Payment minimum age + reference: + - title: The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023, regulation 4(1) + href: https://www.legislation.gov.uk/ssi/2023/302/regulation/4 diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 2d7e58c45c..db32d37490 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -249,7 +249,7 @@ programs: agency: DWP status: complete coverage: UK - variable: JSA + variable: jsa parameter_prefix: gov.dwp.JSA verified_start_year: 2022 notes: Income-based and contribution-based @@ -261,7 +261,7 @@ programs: agency: DWP status: complete coverage: UK - variable: ESA + variable: esa parameter_prefix: gov.dwp.ESA verified_start_year: 2022 notes: Income-related and contribution-based @@ -296,7 +296,7 @@ programs: agency: DWP status: complete coverage: UK - variable: DLA + variable: dla parameter_prefix: gov.dwp.dla verified_start_year: 2022 @@ -319,7 +319,7 @@ programs: status: complete coverage: UK variable: benefit_cap_reduction - parameter_prefix: gov.dwp + parameter_prefix: gov.dwp.benefit_cap verified_start_year: 2022 notes: Universal Credit (uc_benefit_cap) and Housing Benefit (housing_benefit_benefit_cap) each apply their own cap; benefit_cap_reduction is the sum of their reductions @@ -377,7 +377,6 @@ programs: status: complete coverage: UK variable: maternity_allowance - parameter_prefix: gov.dwp verified_start_year: 2022 # --- DfE programs --- diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index fc893866ab..15b042658e 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -72,7 +72,8 @@ # Benefit or tax credits. An adult outside the couple who reports # income-based JSA is on it, as their own claim, only in the years the model # has the scheme open (gov.dwp.JSA.income.active: to 2025). -NOT_MOVED = ["legacy_benefits_closed"] +# Hold the family closure and each separate member's own closure. +NOT_MOVED = ["legacy_benefits_closed", "person_legacy_benefits_closed"] # The first year in which the model pays a carer in Scotland Carer Support # Payment in place of Carer's Allowance: carers_allowance_pre_overlap and diff --git a/policyengine_uk/scenarios/uc_reform.py b/policyengine_uk/scenarios/uc_reform.py index c159821941..eaa03ea02d 100644 --- a/policyengine_uk/scenarios/uc_reform.py +++ b/policyengine_uk/scenarios/uc_reform.py @@ -1,11 +1,12 @@ -from policyengine_uk.model_api import Scenario +from policyengine_uk.model_api import Scenario, Variable from policyengine_uk import Microsimulation from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR -from policyengine_uk.utils.supplied_inputs import set_model_value +from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input import numpy as np import warnings -from policyengine_core.periods import instant +from weakref import WeakKeyDictionary +from policyengine_core.periods import instant, period as period_ # Universal Credit Act 2025: the first tax year of the rebalancing (2026-27, @@ -214,7 +215,97 @@ def _new_claimant_health_element_status( return status +def _lcwra_amount_is_overridden(sim, year: int) -> bool: + """An explicit LCWRA amount keeps precedence over the inactive path.""" + from policyengine_uk.utils.dated_reforms import legacy_override_bounds + from policyengine_uk.tax_benefit_system import ( + reform_parameter_defines, + parameter_reads_calendar_years, + ) + + system = sim.tax_benefit_system + overrides = list(getattr(system, "legacy_overrides", ())) + # Single-day constructor overrides run after this modifier. Respect + # their requested window before caching an inactive award. + for dated in getattr(sim, "_building_dated_reforms", None) or (): + _, pending = dated.read( + reform_parameter_defines(), + calendar_years=parameter_reads_calendar_years(), + ) + overrides.extend(pending) + statutory_sample = instant(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}") + annual_sample = instant(f"{year}-01-01") + return any( + change.path == LCWRA_AMOUNT and change.start <= statutory_sample <= change.stop + for change in getattr(system, "dated_assignments", ()) + ) or any( + change.path == LCWRA_AMOUNT and start <= annual_sample <= stop + for change in overrides + for start, stop in [legacy_override_bounds(change, system.parameters)] + ) + + +def _inactive_health_element_monthly(sim, year: int) -> float: + """The CPI-only counterfactual, independent of previous paid awards. + + Anchor 2026-27 to the pre-Act amount increased by September 2025 CPI, + rounded to monthly pennies. This is a scenario convention, not an + enacted LCWRA rate or a uniquely required SSAA 1992 s.150 rounding rule. + Later years follow the ordinary benefit index from that 2026 anchor. + """ + parameters = sim.tax_benefit_system.parameters + before = parameters(str(FIRST_REBALANCING_YEAR - 1)).gov + anchor = round( + float(before.dwp.universal_credit.elements.disabled.amount) + * _relevant_cpi_factor(sim, FIRST_REBALANCING_YEAR), + 2, + ) + anchor_index = parameters(str(FIRST_REBALANCING_YEAR)).gov.benefit_uprating_cpi + return anchor * parameters(str(year)).gov.benefit_uprating_cpi / anchor_index + + +# Only functions registered here are this internal amount wrapper. External +# wrappers, even ones copying function attributes, keep their own provenance. +_INACTIVE_HEALTH_ELEMENT_ORIGINS = WeakKeyDictionary() + + +def lcwra_formula_origin(formula, period): + """Resolve the formula delegated by our internal LCWRA amount wrapper.""" + if formula is None: + return None + original = _INACTIVE_HEALTH_ELEMENT_ORIGINS.get(formula) + return original.get_formula(period) if original is not None else formula + + +def _add_inactive_health_element_formula(sim): + """Keep the counterfactual as calculated output, invalidated by reforms.""" + system = sim.tax_benefit_system + original = system.get_variable("uc_LCWRA_element") + if getattr(original, "_inactive_health_element_formula", False): + return + + class uc_LCWRA_element(Variable): + def formula(benunit, period, parameters): + current = original.get_formula(period)(benunit, period, parameters) + year = period.start.year + if year not in REBALANCING_YEARS: + return current + simulation = benunit.simulation + p = parameters(period).gov.dwp.universal_credit + if p.rebalancing.active or _lcwra_amount_is_overridden(simulation, year): + return current + amount = p.elements.disabled.amount + if amount == 0: + return current + return current * _inactive_health_element_monthly(simulation, year) / amount + + _INACTIVE_HEALTH_ELEMENT_ORIGINS[uc_LCWRA_element.formula] = original + variable = system.update_variable(uc_LCWRA_element) + variable._inactive_health_element_formula = True + + def add_universal_credit_reform(sim: Microsimulation): + _add_inactive_health_element_formula(sim) rebalancing = sim.tax_benefit_system.parameters.gov.dwp.universal_credit.rebalancing generator = np.random.default_rng(43) @@ -227,6 +318,14 @@ def add_universal_credit_reform(sim: Microsimulation): is_data = built_from_data(sim) for year in REBALANCING_YEARS: if not rebalancing.active(year): + population = sim.get_variable_population("uc_LCWRA_element") + if ( + supplied_input(population, "uc_LCWRA_element", period_(year)) + is not None + ): + continue + # Recalculate through the formula, preserving user-supplied awards. + sim.delete_arrays("uc_LCWRA_element", year) continue is_post_2025_claimant = _new_claimant_health_element_status( sim, year, uc_seed < new_claimant_share(year), is_data diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index cfd1f67801..1c5565ebb5 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -4,7 +4,7 @@ from collections import OrderedDict from dataclasses import replace from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple # PolicyEngine core imports from policyengine_core.parameters import Parameter, ParameterNode @@ -79,8 +79,25 @@ convert_to_fiscal_year_parameters, ) +from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_descendants, + canonical_ni_processing, + validate_ni_scale_compatibility, + canonical_ni_uprating_references, + canonicalize_ni_parameter_path, +) + logger = logging.getLogger(__name__) + +def _install_parameters(system, parameters: ParameterNode) -> None: + # A Core Reform delegates unknown instance methods to its baseline. Keep + # this orchestration local and call the public base operation explicitly: + # Reform.modify_parameters would also start from the baseline's tree. + TaxBenefitSystem.modify_parameters(system, lambda _: parameters) + + # Module constants COUNTRY_DIR = Path(__file__).parent DEFAULT_DATASET_ENV_VAR = "POLICYENGINE_UK_DEFAULT_DATASET" @@ -176,6 +193,7 @@ def process_parameter_tree( written just after the step that generates it (see ``_add_generated_parameters``). """ + validate_ni_scale_compatibility(parameters) parameters = _add_generated_parameters(parameters, generated) # Create baseline parameters for reform comparisons @@ -188,8 +206,10 @@ def process_parameter_tree( parameters.add_child("baseline", baseline) # Apply general parameter operations - parameters = propagate_parameter_metadata(parameters) - parameters = uprate_parameters(parameters) + with canonical_ni_processing(parameters): + propagate_parameter_metadata(parameters) + with canonical_ni_uprating_references(parameters): + uprate_parameters(parameters) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the # gov tree, before annualisation, and the trees outside it (household), @@ -239,11 +259,14 @@ def neutralize_variable(self, variable_name: str) -> None: super().neutralize_variable(part) def reset_parameter_caches(self): - """Reset all caches in the tax-benefit system.""" - self._parameters_at_instant_cache = {} - for parameter in self.parameters.get_descendants(): - parameter._at_instant_cache = {} - self.parameters._at_instant_cache = {} + """Clear dated views through their Core owners, retaining the tree.""" + validate_ni_scale_compatibility(self.parameters) + for node in [self.parameters, *canonical_descendants(self.parameters)]: + if isinstance(node, ParameterNode): + # The public operation clears this node and its ancestors; + # root-only clearing would leave cached descendants behind. + node.clear_parent_cache() + _install_parameters(self, self.parameters) def reset_parameters(self) -> None: """Reset parameters by reloading from the parameters directory. @@ -253,7 +276,6 @@ def reset_parameters(self) -> None: them survive the reload. Only a processed tree is kept, and a second reset before processing keeps the first one. """ - self._parameters_at_instant_cache = {} current = getattr(self, "parameters", None) if ( getattr(self, "_parameters_before_reset", None) is None @@ -262,7 +284,7 @@ def reset_parameters(self) -> None: ): self._parameters_before_reset = current self.load_parameters(self.parameters_dir) - self.parameters = add_removed_parameter_aliases(self.parameters) + _install_parameters(self, add_removed_parameter_aliases(self.parameters)) def process_parameters(self) -> None: """Process and transform parameters with UK-specific adjustments. @@ -284,7 +306,7 @@ def process_parameters(self) -> None: """ previous = (self.parameters, self._projected_timelines, self._raw_parameters) before_reset = getattr(self, "_parameters_before_reset", None) - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() try: # Processing changes the tree it is given, so it gets a copy: if # it fails, the system keeps the tree it had. @@ -343,12 +365,12 @@ def apply_parameter_assignments( sorted(assignments, key=lambda assignment: assignment.order) ) ] - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() statutory = self._raw_parameters is None key = _assignments_key(self.dated_assignments) if statutory else None cached = _rebuilt_parameters_cache.get(key) if key is not None else None if cached is not None: - self.parameters = cached[0].clone() + _install_parameters(self, cached[0].clone()) # Shared with the memo: read only through the copying # ``projected_timelines`` property. self._projected_timelines = cached[1] @@ -392,7 +414,7 @@ def parameters_with(self, assignments: List[ParameterAssignment]) -> ParameterNo returned is never this system's own. """ probe = copy.copy(self) - probe.parameters = self.parameters.clone() + _install_parameters(probe, self.parameters.clone()) probe.apply_parameter_assignments(assignments) return probe.parameters @@ -452,7 +474,8 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # reloaded parameters are part of it. self._raw_parameters = raw.clone() if not assignments: - self.parameters, self._projected_timelines = process_parameter_tree(raw) + parameters, self._projected_timelines = process_parameter_tree(raw) + _install_parameters(self, parameters) return # Changes to parameters processing generates are written as each is # generated; the rest go on the statutory timeline first. @@ -468,9 +491,10 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: projected = prior_law_projection(on_timeline, unreformed, statutory, generated) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) - self.parameters, self._projected_timelines = process_parameter_tree( + parameters, self._projected_timelines = process_parameter_tree( raw, unreformed, generated=generated ) + _install_parameters(self, parameters) def _apply_legacy_overrides(self) -> None: if self.legacy_overrides: @@ -478,29 +502,9 @@ def _apply_legacy_overrides(self) -> None: def __init__(self, capital_gains_projection: CapitalGainsProjection | None = None): """Initialize the UK tax-benefit system with entities and parameters.""" - self._parameters_at_instant_cache: Dict[str, Any] = {} - self.variables = {} + super().__init__(entities=[Person, BenUnit, Household]) self.capital_gains_projection = capital_gains_projection - # Create copies of entity classes to avoid modifying originals - person, benunit, household = ( - copy.copy(Person), - copy.copy(BenUnit), - copy.copy(Household), - ) - - # Set up entities - self.entities = [person, benunit, household] - self.person_entity = person - self.group_entities = [benunit, household] - self.group_entity_keys = [entity.key for entity in self.group_entities] - - # Link entities to this tax-benefit system - for entity in self.entities: - entity.set_tax_benefit_system(self) - - self.variable_module_metadata = {} - # Load all variables from the variables directory self.add_variables_from_directory(COUNTRY_DIR / "variables") @@ -528,8 +532,7 @@ def __init__(self, capital_gains_projection: CapitalGainsProjection | None = Non # the full pipeline (saves ~0.5s from convert_to_fiscal_year_parameters). # Reforms rebuild the parameters from the statutory tree through # apply_parameter_assignments, so they still get the full pipeline. - self._parameters_at_instant_cache = {} - self.parameters = _processed_parameters_cache.clone() + _install_parameters(self, _processed_parameters_cache.clone()) else: self.reset_parameters() _statutory_parameters_cache = self.parameters.clone() @@ -579,6 +582,8 @@ def _leaf_paths(node: ParameterNode, prefix: str = "") -> List[str]: paths = [] for key, child in node.children.items(): path = f"{prefix}.{key}" if prefix else key + if isinstance(child, LegacyNIParameter): + continue if isinstance(child, Parameter): paths.append(path) elif isinstance(child, ParameterNode): @@ -603,7 +608,9 @@ def generated_parameter_defines() -> Callable[[str], bool]: _generated_paths_cache = frozenset( path for path in _leaf_paths(generated) - if not statutory(path) and canonicalize_lsr_parameter_path(path) == path + if not statutory(path) + and canonicalize_ni_parameter_path(canonicalize_lsr_parameter_path(path)) + == path ) return _generated_paths_cache.__contains__ diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py index b605219a9b..2ec0aa18e6 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_compare.py @@ -1446,3 +1446,57 @@ def test_withheld_decile_years_are_explained_not_called_missing(): assert note in text and "need household detail" not in text text = _deciles({"deciles": {}, "decile_notes": []}, [2026]) assert "need household detail" in text + + +@pytest.mark.parametrize( + "fields", + [("python",), ("machine",), ("python", "machine")], + ids=["python", "machine", "both-fields"], +) +@pytest.mark.parametrize( + "sides", + [("base",), ("head",), ("base", "head")], + ids=["base", "head", "both-sides"], +) +def test_issue_2499_data_axis_requires_runtime_identity_on_each_side(fields, sides): + measurements = {"base": _on("1" * 64), "head": _on("2" * 64)} + complete = compare(measurements["base"], measurements["head"], SPEC, axis="data") + assert complete["status"] == "pass", complete["failures"] + for side in sides: + for field in fields: + del measurements[side]["runtime"][field] + + summary = compare(measurements["base"], measurements["head"], SPEC, axis="data") + + assert summary["status"] == "fail" + for field in fields: + assert any(field in failure.lower() for failure in summary["failures"]) + + +@pytest.mark.parametrize("field", ["python", "machine"]) +@pytest.mark.parametrize("value", [None, ""], ids=["null", "empty"]) +def test_issue_2499_data_axis_rejects_empty_runtime_identity(field, value): + base, head = _on("1" * 64), _on("2" * 64) + base["runtime"][field] = head["runtime"][field] = value + + summary = compare(base, head, SPEC, axis="data") + + assert summary["status"] == "fail" + assert any(field in failure.lower() for failure in summary["failures"]) + + +def test_issue_2499_complete_runtime_identity_allows_different_datasets(): + summary = compare(_on("1" * 64), _on("2" * 64), SPEC, axis="data") + + assert summary["status"] == "pass", summary["failures"] + + +def test_issue_2499_model_axis_preserves_missing_runtime_identity_behavior(): + base, head = _on("1" * 64), _on("1" * 64) + for result in (base, head): + del result["runtime"]["python"] + del result["runtime"]["machine"] + + summary = compare(base, head, SPEC, axis="model") + + assert summary["status"] == "pass", summary["failures"] diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py new file mode 100644 index 0000000000..d5ba4fb1f0 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -0,0 +1,184 @@ +"""Every variable declaring `uprating` should appear in uprating_indices.yaml. + +Through 2030, `uprating_indices.yaml` is the only mechanism that applies +uprating, via `apply_single_year_uprating` and `reset_growthfactor_uprating` +in `data/economic_assumptions.py`. The class-level `uprating = "..."` +attribute on a Variable is read only by the fallback in +`_unmapped_uprating_indices`, which grows unlisted columns after +LEGACY_PROJECTION_END_YEAR (2030). + +A variable can therefore declare an index and silently stay flat from the +dataset year to 2030. That is what #1859 found for electricity and gas +consumption, and #1862 for three more columns. This guard makes the gap fail loudly at the point it is +introduced rather than surfacing in a downstream study. +""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +UPRATING_INDICES = Path(__file__).parents[2] / "data" / "uprating_indices.yaml" + +# Variables that declare an index but are deliberately not in the YAML, with +# the reason. Anything else failing this test is a genuine gap. +KNOWN_UNUPRATED = { + # gov.dft.rail.ridership_index is a CUMULATIVE index (2020 = 1.0), not a + # year-on-year growth rate, so it cannot go in a yoy_growth list without + # compounding wrongly: doing so uprates rail_usage by ~684% over 2024-27. + # Uprating this needs a yoy series, not a YAML entry (#1862). + "rail_usage", + # Reported counterpart of employee_pension_contributions, which IS + # uprated; the reported variable feeds imputation rather than results. + "employee_pension_contributions_reported", + # policyengine-uk-data stores energy spend already priced at the dataset's + # target price level (Ofgem Q2 2026 unit rates), so CPI-uprating from the + # data year re-applies price changes the build has priced in (#1868). + "domestic_energy_consumption", + "electricity_consumption", + "gas_consumption", + # Grown by country in uprate_water_and_sewerage_charges (#2501): Ofwat's + # series for England and Wales, Scottish Water's for Scotland, none for + # Northern Ireland. A YAML entry would grow it a second time. + "water_and_sewerage_charges", +} + +# Variables the YAML deliberately uprates by an index other than the one they +# declare, with the reason. +KNOWN_INDEX_OVERRIDES = { + # Declares ons.household_interest_income; the YAML applies per_capita.gdp. + # Flagged in #1862 for someone with the context to say which is intended. + "savings_interest_income", +} + + +def _listed_indices(): + listed = yaml.safe_load(UPRATING_INDICES.read_text()) + return { + variable: index for index, variables in listed.items() for variable in variables + } + + +def test_declared_uprating_variables_are_in_the_yaml(): + listed = _listed_indices() + missing = sorted( + name + for name, variable in CountryTaxBenefitSystem().variables.items() + if getattr(variable, "uprating", None) + and name not in listed + and name not in KNOWN_UNUPRATED + ) + assert not missing, ( + "these variables declare `uprating` but are absent from " + f"uprating_indices.yaml, so they are not uprated through 2030: " + f"{missing}. Add them to the matching index block, or to " + "KNOWN_UNUPRATED with a reason." + ) + + +def _uprated_by_another_index(declared, listed_index): + # The YAML keys are yoy_growth twins of the declared indices. + equivalent = declared.replace(".indices.", ".yoy_growth.") + return listed_index not in (declared, equivalent) + + +def test_the_yaml_uprates_by_the_declared_index(): + listed = _listed_indices() + mismatched = [] + for name, variable in CountryTaxBenefitSystem().variables.items(): + declared = getattr(variable, "uprating", None) + if not declared or name not in listed or name in KNOWN_INDEX_OVERRIDES: + continue + if _uprated_by_another_index(declared, listed[name]): + mismatched.append((name, declared, listed[name])) + assert not mismatched, ( + "these variables are uprated by a different index from the one they " + f"declare: {mismatched}" + ) + + +def _stale_exceptions(variables, listed, unuprated, index_overrides): + """Return a message for every exception whose reason no longer holds.""" + stale = [] + for name in sorted(unuprated | index_overrides): + if name not in variables: + stale.append(f"{name} no longer exists; drop the exception") + elif not getattr(variables[name], "uprating", None): + stale.append(f"{name} no longer declares uprating; drop the exception") + for name in sorted(unuprated & variables.keys()): + if name in listed: + stale.append( + f"{name} is now in uprating_indices.yaml under {listed[name]}; " + "remove it from KNOWN_UNUPRATED" + ) + for name in sorted(index_overrides & variables.keys()): + declared = getattr(variables[name], "uprating", None) + if not declared: + continue + if name not in listed: + stale.append( + f"{name} is no longer in uprating_indices.yaml, so there is no " + "index to override; remove it from KNOWN_INDEX_OVERRIDES" + ) + elif not _uprated_by_another_index(declared, listed[name]): + stale.append( + f"{name} is now uprated by its declared index ({declared}); " + "remove it from KNOWN_INDEX_OVERRIDES" + ) + return stale + + +def test_every_known_exception_still_holds(): + """Keeps the exception lists honest: an entry whose reason no longer + holds should be removed rather than left to hide a future gap.""" + stale = _stale_exceptions( + CountryTaxBenefitSystem().variables, + _listed_indices(), + KNOWN_UNUPRATED, + KNOWN_INDEX_OVERRIDES, + ) + assert not stale, "stale uprating exceptions:\n" + "\n".join(stale) + + +class _Declares: + def __init__(self, uprating=None): + self.uprating = uprating + + +CPI = "gov.economic_assumptions.indices.obr.consumer_price_index" +CPI_YOY = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +GDP_YOY = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" + + +def test_the_guard_passes_exceptions_that_still_hold(): + variables = {"a": _Declares(CPI), "b": _Declares(CPI)} + listed = {"b": GDP_YOY} + assert _stale_exceptions(variables, listed, {"a"}, {"b"}) == [] + + +def test_the_guard_flags_an_unuprated_exception_added_to_the_yaml(): + variables = {"a": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {"a": CPI_YOY}, {"a"}, set()) + assert "remove it from KNOWN_UNUPRATED" in stale + + +def test_the_guard_flags_an_override_that_now_matches(): + variables = {"b": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {"b": CPI_YOY}, set(), {"b"}) + assert "remove it from KNOWN_INDEX_OVERRIDES" in stale + + +def test_the_guard_flags_an_override_dropped_from_the_yaml(): + variables = {"b": _Declares(CPI)} + (stale,) = _stale_exceptions(variables, {}, set(), {"b"}) + assert "remove it from KNOWN_INDEX_OVERRIDES" in stale + + +def test_the_guard_flags_missing_or_undeclared_exceptions(): + variables = {"a": _Declares(None)} + stale = _stale_exceptions(variables, {}, {"a", "gone"}, set()) + assert stale == [ + "a no longer declares uprating; drop the exception", + "gone no longer exists; drop the exception", + ] diff --git a/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json b/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json new file mode 100644 index 0000000000..7f556e97e8 --- /dev/null +++ b/policyengine_uk/tests/fixtures/ni_legacy_parameter_metadata.json @@ -0,0 +1,266 @@ +{ + "api_commit": "71dba65b3780f0bb4699d4dcbfe179e5f1506c78", + "extraction": "Exact source-extracted pinned API method and exact helper against unmodified production before migration. Not the installed/full API application.", + "parameters": { + "gov.hmrc.national_insurance.class_1.rates.employee.additional": { + "description": "The Class 1 National Insurance additional rate is paid on employment earnings above the Upper Earnings Limit.", + "economy": true, + "household": true, + "label": "NI class 1 additional rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employee.additional", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.02, + "2015-04-01": 0.02, + "2022-04-01": 0.0325, + "2024-04-04": 0.02 + } + }, + "gov.hmrc.national_insurance.class_1.rates.employee.main": { + "description": "The Class 1 National Insurance main rate is paid on employment earnings between the Primary Threshold and the Upper Earnings Limit.", + "economy": true, + "household": true, + "label": "NI class 1 main rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employee.main", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.12, + "2015-04-01": 0.12, + "2022-04-06": 0.1325, + "2022-11-06": 0.12, + "2024-01-01": 0.1, + "2024-04-01": 0.08 + } + }, + "gov.hmrc.national_insurance.class_1.rates.employer": { + "description": "National Insurance contribution rate by employers on earnings above the Secondary Threshold.", + "economy": true, + "household": true, + "label": "NI employer rate", + "parameter": "gov.hmrc.national_insurance.class_1.rates.employer", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.138, + "2015-04-01": 0.138, + "2022-04-06": 0.1505, + "2022-11-06": 0.138, + "2025-01-01": 0.15 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold": { + "description": "The Primary Threshold is the lower bound for the main rate of Class 1 National Insurance.", + "economy": true, + "household": true, + "label": "NI primary threshold", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 155, + "2015-04-06": 155, + "2016-04-06": 155, + "2017-04-06": 157, + "2018-04-06": 162, + "2019-04-06": 166, + "2020-04-06": 183, + "2021-04-06": 184, + "2022-04-06": 190, + "2022-07-01": 241.73, + "2026-04-06": 241.73, + "2027-04-06": 241.73, + "2028-04-06": 241.73, + "2029-04-06": 241.73, + "2030-04-06": 241.73, + "2031-01-01": 246.73, + "2032-01-01": 251.73, + "2033-01-01": 256.92, + "2034-01-01": 262.12, + "2035-01-01": 267.5, + "2036-01-01": 272.88, + "2037-01-01": 278.46, + "2038-01-01": 284.04, + "2039-01-01": 289.81 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold": { + "description": "Secondary threshold for National Insurance.", + "economy": true, + "household": true, + "label": "NI secondary threshold", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 156, + "2015-04-06": 156, + "2016-04-06": 156, + "2017-04-06": 157, + "2018-04-06": 162, + "2019-04-06": 166, + "2020-04-06": 169, + "2021-04-06": 170, + "2022-04-06": 175, + "2024-01-01": 175, + "2025-04-06": 96.153846, + "2026-04-06": 96.153846, + "2027-04-06": 96.153846, + "2028-04-06": 96.153846, + "2029-04-06": 96.153846, + "2030-04-06": 96.153846, + "2031-01-01": 98.07692292, + "2032-01-01": 100.0384613784, + "2033-01-01": 102.039230605968, + "2034-01-01": 104.08001521808735, + "2035-01-01": 106.16161552244911, + "2036-01-01": 108.2848478328981, + "2037-01-01": 110.45054478955605, + "2038-01-01": 112.65955568534717, + "2039-01-01": 114.91274679905412 + } + }, + "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit": { + "description": "The Upper Earnings Limit is the upper bound for the main rate of Class 1 National Insurance.", + "economy": true, + "household": true, + "label": "NI upper earnings limit", + "parameter": "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", + "period": "week", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 815, + "2015-04-06": 815, + "2016-04-06": 827, + "2017-04-06": 866, + "2018-04-06": 892, + "2019-04-06": 962, + "2020-04-06": 962, + "2021-04-06": 966.73, + "2026-04-06": 966.73, + "2027-04-06": 966.73, + "2028-04-06": 966.73, + "2029-04-06": 966.73, + "2030-04-06": 966.73, + "2031-01-01": 987.12, + "2032-01-01": 1007.5, + "2033-01-01": 1028.08, + "2034-01-01": 1050.58, + "2035-01-01": 1073.27, + "2036-01-01": 1095.96, + "2037-01-01": 1118.85, + "2038-01-01": 1141.73, + "2039-01-01": 1164.81 + } + }, + "gov.hmrc.national_insurance.class_4.rates.additional": { + "description": "The additional National Insurance rate paid above the Upper Profits Limit for self-employed profits.", + "economy": true, + "household": true, + "label": "NI class 4 additional rate", + "parameter": "gov.hmrc.national_insurance.class_4.rates.additional", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.02, + "2015-04-06": 0.02, + "2022-04-06": 0.0273, + "2023-04-06": 0.02 + } + }, + "gov.hmrc.national_insurance.class_4.rates.main": { + "description": "The main National Insurance rate paid between the Lower and Upper Profits Limits for self-employed profits.", + "economy": true, + "household": true, + "label": "NI class 4 main rate", + "parameter": "gov.hmrc.national_insurance.class_4.rates.main", + "period": null, + "type": "parameter", + "unit": "/1", + "values": { + "2015-01-01": 0.09, + "2015-04-06": 0.09, + "2022-04-06": 0.0973, + "2023-04-06": 0.09, + "2024-04-06": 0.06 + } + }, + "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit": { + "description": "The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate.", + "economy": true, + "household": true, + "label": "NI lower profits limit", + "parameter": "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", + "period": "year", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 8060, + "2015-04-06": 8060, + "2017-04-06": 8164, + "2018-04-06": 8424, + "2019-04-06": 8632, + "2020-04-06": 9500, + "2021-04-06": 9568, + "2022-04-06": 11908, + "2023-04-06": 12570, + "2026-04-06": 12570, + "2027-04-06": 12570, + "2028-04-06": 12570, + "2029-04-06": 12570, + "2030-04-06": 12570, + "2031-01-01": 12830.0, + "2032-01-01": 13090.0, + "2033-01-01": 13360.0, + "2034-01-01": 13630.0, + "2035-01-01": 13910.0, + "2036-01-01": 14190.0, + "2037-01-01": 14480.0, + "2038-01-01": 14770.0, + "2039-01-01": 15070.0 + } + }, + "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit": { + "description": "The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate.", + "economy": true, + "household": true, + "label": "NI upper profits limit", + "parameter": "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", + "period": "year", + "type": "parameter", + "unit": "currency-GBP", + "values": { + "2015-01-01": 42385, + "2015-04-06": 42385, + "2016-04-06": 43000, + "2017-04-06": 45000, + "2018-04-06": 46350, + "2019-04-06": 50000, + "2021-04-06": 50270, + "2026-04-06": 50270, + "2027-04-06": 50270, + "2028-04-06": 50270, + "2029-04-06": 50270, + "2030-04-06": 50270, + "2031-01-01": 51330.0, + "2032-01-01": 52390.0, + "2033-01-01": 53460.0, + "2034-01-01": 54630.0, + "2035-01-01": 55810.0, + "2036-01-01": 56990.0, + "2037-01-01": 58180.0, + "2038-01-01": 59370.0, + "2039-01-01": 60570.0 + } + } + }, + "source_base": "e1601dbe4feb15e71d0d59b7a57f9d860741c068" +} diff --git a/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py b/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py new file mode 100644 index 0000000000..77b9c3af9f --- /dev/null +++ b/policyengine_uk/tests/fixtures/ni_metadata_api_contract.py @@ -0,0 +1,179 @@ +"""Source-extracted API contract, not an installed/full API execution. + +Country.build_parameters and get_safe_json are copied without body changes from +PolicyEngine/policyengine-api commit 71dba65b3780f0bb4699d4dcbfe179e5f1506c78. +country.py SHA256 cfbb900691d45bd95e05b08e53bf72708ac75ee82d1647c04ff09240a92e61bb; +utils/json.py SHA256 acef75a37b1d90cc52c0bda57db47ffd2348eaa400d4674d244c8090b47ddc61. +https://github.com/PolicyEngine/policyengine-api/blob/71dba65b3780f0bb4699d4dcbfe179e5f1506c78/policyengine_api/country.py#L371 +https://github.com/PolicyEngine/policyengine-api/blob/71dba65b3780f0bb4699d4dcbfe179e5f1506c78/policyengine_api/utils/json.py#L34 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +if TYPE_CHECKING: + from policyengine_api.json_types import JSONValue + +from policyengine_core.parameters.operations.get_parameter import get_parameter +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform +from policyengine_core.parameters import ( + Parameter, + ParameterNode, + ParameterScale, + ParameterScaleBracket, +) + + +def get_safe_json(value: object) -> JSONValue: + # This function is used when constructing metadata, + # which will be consumed by JS, hence Infinity, not .inf + if isinstance(value, (int, float)): + if value == np.inf: + return "Infinity" + if value == -np.inf: + return "-Infinity" + return value + if isinstance(value, str): + return value + if isinstance(value, dict): + return {k: get_safe_json(v) for k, v in value.items()} + if isinstance(value, list): + return [get_safe_json(v) for v in value] + return None + + +def build_parameters(self) -> dict: + parameters = self.tax_benefit_system.parameters + parameter_data = {} + for parameter in parameters.get_descendants(): + if "gov" != parameter.name[:3]: + continue + end_name = parameter.name.split(".")[-1] + if isinstance(parameter, ParameterScale): + parameter.propagate_units() + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + } + elif isinstance(parameter, ParameterScaleBracket): + bracket_index = int(parameter.name[parameter.name.index("[") + 1 : -1]) + # Set the label to 'first bracket' for the first bracket, 'second bracket' for the second, etc. + bracket_label = f"bracket {bracket_index + 1}" + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", bracket_label), + } + elif isinstance(parameter, Parameter): + parameter_data[parameter.name] = { + "type": "parameter", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + "unit": parameter.metadata.get("unit"), + "period": parameter.metadata.get("period"), + "values": { + value_at_instant.instant_str: get_safe_json(value_at_instant.value) + for value_at_instant in parameter.values_list + }, + "economy": parameter.metadata.get("economy", True), + "household": parameter.metadata.get("household", True), + } + elif isinstance(parameters, ParameterNode): + parameter_data[parameter.name] = { + "type": "parameterNode", + "parameter": parameter.name, + "description": parameter.description, + "label": parameter.metadata.get("label", end_name.replace("_", " ")), + "economy": parameter.metadata.get("economy", True), + "household": parameter.metadata.get("household", True), + } + return parameter_data + + +def _normalize_reform_values( + self, + reform: dict, + system: TaxBenefitSystem, +) -> dict: + normalized_reform = {} + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(system.parameters, parameter_name) + normalized_reform[parameter_name] = {} + for time_period, value in parameter_updates.items(): + node_type = type(parameter.values_list[-1].value) + if node_type is int: + node_type = float + try: + value = float(value) + except Exception: + pass + normalized_reform[parameter_name][time_period] = node_type(value) + return normalized_reform + + +def _apply_reform_to_system( + self, + system: TaxBenefitSystem, + reform: dict, +) -> None: + for parameter_name, parameter_updates in reform.items(): + parameter = get_parameter(system.parameters, parameter_name) + for time_period, value in parameter_updates.items(): + start_instant, end_instant = time_period.split(".") + parameter.update( + start=instant(start_instant), + stop=instant(end_instant), + value=value, + ) + + +def create_policy_reform(policy_data: dict) -> dict: + """ + Create a policy reform. + + Args: + policy_data (dict): The policy data. + + Returns: + dict: The reform. + """ + + def modify_parameters(parameters: ParameterNode) -> ParameterNode: + for path, values in policy_data.items(): + node = parameters + for step in path.split("."): + if "[" in step: + step, index = step.split("[") + index = int(index[:-1]) + node = node.children[step].brackets[index] + else: + node = node.children[step] + for period, value in values.items(): + start, end = period.split(".") + node_type = type(node.values_list[-1].value) + if node_type is int: + node_type = float # '0' is of type int by default, but usually we want to cast to float. + if node.values_list[-1].value is None: + node_type = float + node.update( + start=instant(start), + stop=instant(end), + value=node_type(value), + ) + + return parameters + + class reform(Reform): + def apply(self) -> None: + self.modify_parameters(modify_parameters) + + return reform diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 8ff44c9cf0..367f3102ee 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -33,9 +33,9 @@ (2)(g)), so the limit is 24 either way. - The claimant is a member who reports the award; when the claimant or partner reports one, only they are candidates. -- Both screens also end the award once the family's legacy benefits have - closed (legacy_benefits_closed, from PolicyEngine/policyengine-uk#2119), - read here as the model applies it; see legacy_benefits_closed below. +- Reports whose own award is closed are excluded before selecting claimants. + The existing annual proxy is read separately for the couple and for each + other adult; it is not a notice or deadline simulation. """ import math @@ -143,19 +143,13 @@ def esa_claimant_in_remunerative_work(adult, parameters): def legacy_benefits_closed(couple, others, parameters): - """legacy_benefits_closed for a benefit unit whose members (couple + - others, every member who may report) report no tax credits. - - Once Income Support or income-based JSA has closed (their active - parameters), a report of it closes the family's legacy benefits, unless - someone reports Pension Credit or the claimant and partner have all - reached the qualifying age for it (SI 2014/1230 reg 8(2B)). This is the - model's reading, not the law's: the model reads every member's reports - and ends every member's award, where the migration notice goes to the - claimant and partner (reg 44(1), (4)) and ends their awards (reg 46(1)). - When the result depends on the claimant's and partner's ages, their ages - must keep clear of 65 to 69, where the qualifying age moves; an assertion - checks this.""" + """Annual closure proxy for the claimant's and partner's own award. + + Reports by other members neither close nor shield it (SI 2014/1230 + regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a + notice/deadline model. No tax-credit reports are generated here. Ages + avoid the changing Pension Credit qualifying-age boundary. + """ dwp = parameters.gov.dwp members = couple + others assert not any( @@ -165,7 +159,7 @@ def legacy_benefits_closed(couple, others, parameters): ), "tax credit closures are not read here" def reports(variable): - return any(member.get(variable, 0) > 0 for member in members) + return any(member.get(variable, 0) > 0 for member in couple) closed_report = ( reports("income_support_reported") and not dwp.income_support.active @@ -176,14 +170,24 @@ def reports(variable): return not all(adult["age"] >= 70 for adult in couple) -def candidates(couple, others, reported): +def candidates(couple, others, reported, parameters): """Indexes (into couple + others) of members who could be the claimant.""" members = couple + others - couple_reports = any(adult.get(reported, 0) > 0 for adult in couple) + couple_closed = legacy_benefits_closed(couple, [], parameters) + open_reports = [ + member.get(reported, 0) > 0 + and not ( + couple_closed + if i < len(couple) + else legacy_benefits_closed([member], [], parameters) + ) + for i, member in enumerate(members) + ] + couple_reports = any(open_reports[: len(couple)]) return [ i - for i, member in enumerate(members) - if member.get(reported, 0) > 0 and (i < len(couple) or not couple_reports) + for i, reports in enumerate(open_reports) + if reports and (i < len(couple) or not couple_reports) ] @@ -206,13 +210,9 @@ def other_member_works(i): passes = any( not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) - for i in candidates(couple, others, "esa_income_reported") - ) - return ( - passes - and capital <= ESA.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "esa_income_reported", parameters) ) + return passes and capital <= ESA.capital.limit def jsa_screen(couple, others, capital, parameters): @@ -234,10 +234,6 @@ def other_member_works(i): passes = any( weekly_hours(members[i]) < WORK.claimant_hours and not other_member_works(i) - for i in candidates(couple, others, "jsa_income_reported") - ) - return ( - passes - and capital <= JSA.income.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "jsa_income_reported", parameters) ) + return passes and capital <= JSA.income.capital.limit diff --git a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py new file mode 100644 index 0000000000..65148488ff --- /dev/null +++ b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py @@ -0,0 +1,54 @@ +"""The 2024-25 annual allowance charge on the certified dataset against HMRC. + +HMRC, Private pension statistics (July 2026), 2024-25 provisional: +- Table 7: 30,440 individuals reported pension contributions exceeding their + annual allowance through Self Assessment, with £672m of contributions above + it. Self Assessment reports only the excess over the allowance after unused + allowance is carried forward (HMRC, HS345 Pension savings tax charges, 2025). +- Table 6: annual allowance charges of £0.3bn, rounded to the nearest £0.1bn. + +The model's count of payers, its charge and its chargeable amount in 2024 must +each lie within half to one and a half times these. The excess does not depend +on marginal rates, so it is the cleaner of the two amounts. + +On the certified Enhanced FRS all three are several times HMRC's. The known +data gaps behind that are chiefly policyengine-uk-data#606: the dataset +imputes employer pension contributions as three times employee contributions. +With employer contributions at a third of their stored values all three fall +within the band, so this is a strict expected failure until the data is +fixed. The test reports no model value: the counts rest on survey records. +""" + +import pytest + +from policyengine_uk import Microsimulation + +YEAR = 2024 +HMRC_PAYERS = 30_440 +HMRC_EXCESS = 672e6 +HMRC_CHARGE = 0.3e9 +TOLERANCE = 0.5 + + +def _within(value, target): + return (1 - TOLERANCE) * target <= value <= (1 + TOLERANCE) * target + + +@pytest.mark.microsimulation +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: " + "employer pension contributions imputed as three times employee " + "contributions", +) +def test_2024_annual_allowance_charge_and_payers_are_near_hmrc(): + sim = Microsimulation() + charge = sim.calculate("personal_pension_contributions_tax", YEAR) + excess = sim.calculate("pension_annual_allowance_chargeable_amount", YEAR) + near_hmrc = bool( + _within((charge > 0).sum(), HMRC_PAYERS) + and _within(charge.sum(), HMRC_CHARGE) + and _within(excess.sum(), HMRC_EXCESS) + ) + assert near_hmrc, "The 2024 annual allowance charge is outside HMRC's band." diff --git a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py index f6fe96de52..d292b0c2a8 100644 --- a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -4,11 +4,11 @@ This tests the £2,000 salary sacrifice cap policy announced in Autumn Budget 2025, which takes effect from April 2029. -Methodology (matching blog at https://policyengine.org/uk/research/uk-salary-sacrifice-cap): -- Employees redirect excess above £2,000 to regular pension contributions -- Regular pension contributions get income tax relief but NOT NI relief +Current NI-only model routing (historical population benchmarks below): +- Excess above £2,000 is NIC-only earnings; pension contributions stay employer payments +- Ordinary income-tax treatment is unchanged; only NIC exemption is capped - Broad-base haircut: employers spread NI costs across ALL workers (~0.16% of employment income) -- Revenue comes from NI on the redirected excess +- Revenue comes from NI on the excess included in NI earnings Expected revenue: ~£3.3 billion in 2029-30 @@ -60,10 +60,10 @@ def test_salary_sacrifice_cap_revenue_impact(baseline_simulation, reform_simulat """ Test that the £2,000 salary sacrifice cap raises ~£3.3 billion. - This matches the PolicyEngine blog methodology: - - Employees redirect excess to regular pension contributions - - Full excess subject to NI (broad-base haircut reduces all workers' income) - - Income tax relief preserved via regular pension contributions + The current NI-only model routing is: + - Excess is included only in NI earnings + - Broad-base haircut reduces all workers' income as a separate assumption + - Employer pension contributions retain their income-tax treatment """ baseline_gov_balance = baseline_simulation.calculate( "gov_balance", POLICY_YEAR @@ -95,7 +95,7 @@ def test_ni_increases_with_reform(baseline_simulation, reform_simulation): """ Test that total NI increases when the cap is applied. - The reform adds excess salary sacrifice back to employment income, + The reform adds excess salary sacrifice only to NIC earnings, which is then subject to NI through the normal Class 1 calculation. """ baseline_ni = baseline_simulation.calculate( @@ -127,9 +127,8 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): """ Test the income tax impact of the reform. - The excess is added to employment income and to employee pension - contributions for relief. Due to pension relief caps, some people - don't get full relief, resulting in a small positive income tax impact. + The excess is NIC-only and creates no member payment or ordinary tax. + The separate broad-base haircut can still affect ordinary tax. """ baseline_tax = baseline_simulation.calculate("income_tax", POLICY_YEAR).sum() reform_tax = reform_simulation.calculate("income_tax", POLICY_YEAR).sum() @@ -141,25 +140,22 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): print(f"Income tax change: £{tax_change / 1e9:.3f}bn") # Income tax change should be small and approximately neutral. - # The reform redirects excess salary sacrifice to employee pension - # contributions (which get income tax relief), while the total pension - # input for Annual Allowance purposes stays the same (no AA charge - # difference). Net effect is a small income tax decrease. + # NIC reclassification preserves actual pension input and ordinary pay. + # The unchanged broad-base haircut remains a separate economic effect. assert abs(tax_change) < 1e9, ( f"Income tax change should be small (<£1bn), got £{tax_change / 1e9:.3f}bn" ) @pytest.mark.microsimulation -def test_excess_redirected_to_pension(reform_simulation): +def test_excess_included_in_ni_earnings(reform_simulation): """ - Test that full excess is redirected to employee pension contributions. + Test that full excess is included in NIC earnings. - The blog assumes employees maintain their total pension contributions - by redirecting the full excess to regular pension contributions. + Physical sacrifice remains an employer pension contribution. """ redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR + "salary_sacrifice_pension_ni_excess", POLICY_YEAR ).sum() # Should be significant (blog says £13.8bn excess - updated to current model) @@ -215,12 +211,11 @@ def test_affected_population(reform_simulation): @pytest.mark.microsimulation -def test_full_excess_redirected(reform_simulation): +def test_full_excess_included_in_ni_earnings(reform_simulation): """ - Test that the full excess is redirected (no targeted haircut). + Test that the full excess is included in NIC earnings (no targeted haircut). - The broad-base haircut reduces ALL workers' employment income, - but the full excess above cap is redirected to regular pension contributions. + The separate broad-base haircut still reduces ALL workers' employment income. """ # Get weighted totals using map_to for proper aggregation. Only people # with pay have a salary sacrifice, so the excess is measured on that. @@ -231,7 +226,7 @@ def test_full_excess_redirected(reform_simulation): ) weights = reform_simulation.calculate("person_weight", POLICY_YEAR) redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR, map_to="person" + "salary_sacrifice_pension_ni_excess", POLICY_YEAR, map_to="person" ) cap = 2000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml index 1edd5322e9..55ffc05f16 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -985,10 +985,11 @@ output: council_tax_reduction_non_dep_deductions: [150, 0] -# The English working-age local schemes keep their own aggregation, which -# charges only other families' non-dependants. Widening the shared -# eligibility must not make a jointly liable sharer bear the head's own son. -- name: Local CTR (Newham), a jointly liable sharer does not bear the head's own in-unit son +# The English working-age local schemes reproduce the Default Scheme's para 9 +# and para 30(5): the head's own son normally resides with both jointly liable +# people, so each bears half his deduction (Newham para 30B: £18.84 a week on +# £384.62, £979.68 a year). 70% of each half of the £1,800 is £630. +- name: Local CTR (Newham), jointly liable families share the deduction for the head's own in-unit son period: 2025 absolute_error_margin: 0.01 input: @@ -1023,8 +1024,8 @@ council_tax: 1_800 output: council_tax_reduction_joint_liability_share: [0.5, 0.5] - newham_council_tax_reduction_non_dep_deductions: [0, 0] - newham_council_tax_reduction: [630, 630] + newham_council_tax_reduction_non_dep_deductions: [489.84, 489.84] + newham_council_tax_reduction: [140.16, 140.16] # SI 2012/2885 reg 9(2)(a): the applicant's own family are not its # non-dependants, so an individual deduction set for the applicant (here as @@ -1361,3 +1362,124 @@ output: uc_non_dependants_counted: [false, true] uc_non_dep_deductions: [0, 1_158.60] + +# SI 2012/2885 reg 9(2)(e) excludes someone liable to pay the applicant on a +# commercial basis, but reg 9(3)(a)(i) keeps as a non-dependant one who is a +# close relative of the applicant or partner (#2559 checks the Welsh and +# Scottish equivalents' wording). The model has no relationship +# input: a member of the household head's own family who pays board is taken +# to be a close relative, so stays a non-dependant. The son is not in +# remunerative work, so pays each scheme's lowest amount for 2026-27. +# England's pensioner scheme: 5.20 a week (SI 2012/2885 Sch 1 para 8(1)(b)). +- name: CTR (England pensioners), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 70 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: ENGLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 270.40 + +# Scotland's working-age scheme: 5.55 a week (SSI 2021/249 reg 90). +- name: CTR (Scotland), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: SCOTLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 288.60 + +# Wales's working-age scheme: 6.70 a week (WSI 2013/3029 Sch 6 para 5). +- name: CTR (Wales), a son who pays his mother board stays her non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_boarder: 2_600 + benunits: + benunit: + members: [mother, son] + households: + household: + members: [mother, son] + country: WALES + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 348.40 + +# Someone outside the household head's family who pays the householder is +# excluded personally (reg 9(2)(e)), even where the rest of their family pays +# nothing; the lodger's son who does not pay is still a non-dependant (see +# "CTR, a lodger's adult son is the applicant's non-dependant"). +- name: CTR (Scotland), a lodger's adult son who pays the householder himself is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + rent_paid_as_lodger: 2_600 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + country: SCOTLAND + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + council_tax_reduction_non_dep_deductions: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index 3fbc09d789..c57c401bbf 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -1179,7 +1179,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_related_esa: [false, true] -- name: A non-dependent adult's own ESA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own ESA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -1205,15 +1205,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. esa_income_claimant_remunerative_work: [false, false, false] - esa_income_eligible: false - esa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_related_esa: [false, false, false] + esa_income_eligible: true + esa_income: 5000 + claimant_or_partner_esa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_related_esa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(h)). @@ -1248,3 +1246,32 @@ # Without the work test the partner's award would count as income-related # ESA of the other member of the couple and bar the claim. income_support_eligible: true + +- name: Issue 2395 outside closed IS report leaves the couple ESA award open + period: 2027 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + esa_income_reported: 5_000 + partner: + age: 30 + is_claimant_or_partner: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 1_000 + benunits: + unit: + members: [claimant, partner, outside] + esa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside] + output: + legacy_benefits_closed: false + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml index bc8af60aad..4df1327e70 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml @@ -141,8 +141,9 @@ pension_credit_assessable_capital: [20_000, 0] # 18,000 + 1,040 = 19,040, above the couple minimum guarantee of 18,889. # The non-dependant's is computed but he cannot have Pension Credit: - # 20,000 - 1,486 income tax - 594.40 National Insurance = 17,919.60. - pension_credit_income: [19_040, 17_919.60] + # 20,000 - 1,486 income tax - 594.40 National Insurance - the 5 pound + # earnings disregard (260) = 17,659.60. + pension_credit_income: [19_040, 17_659.60] guarantee_credit: [0, 0] # 1,045.20 - 0.4 x (19,040 - 18,889) = 1,045.20 - 60.40 = 984.80. savings_credit: [984.80, 0] @@ -169,9 +170,8 @@ members: [person1] # £5 a week for a single claimant with earnings (Sch 4 para 7(a)), # the same as the Pension Credit disregard (SPC Regs 2002 Sch VI - # para 5(a)); 5 x 52 = 260. Pension Credit income in the model - # disregards no earnings, so deducting it gives the Secretary of - # State's figure. + # para 5(a)); 5 x 52 = 260. The Secretary of State's figure is + # already after it, and reg 27(4) deducts only higher amounts. housing_benefit_applicable_income_disregard: 260 households: household: @@ -180,14 +180,15 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # 13,000 - 86 = 12,914, as in case 1. - pension_credit_income: 12_914 - savings_credit: 718.62 + # 13,000 - 86 - the 5 pound Pension Credit earnings disregard (260). + pension_credit_income: 12_654 + # 933.82 - 0.4 x (12,654 - 12,376) = 933.82 - 111.20 = 822.62. + savings_credit: 822.62 in_receipt_of_savings_credit_only: true - # 12,914 + 718.62 - 260 = 13,372.62. - housing_benefit_applicable_income: 13_372.62 - # 7,800 - 0.65 x (13,372.62 - 13,312) = 7,800 - 39.40 = 7,760.60. - housing_benefit: 7_760.60 + # 12,654 + 822.62 = 13,476.62; nothing more is disregarded. + housing_benefit_applicable_income: 13_476.62 + # 7,800 - 0.65 x (13,476.62 - 13,312) = 7,800 - 107.00 = 7,693.00. + housing_benefit: 7_693 - name: Case 5, savings credit only lone parent in work, childcare charges and the lone parent and additional earnings disregards are deducted. period: 2026 @@ -196,7 +197,7 @@ people: person1: age: 78 - state_pension: 11_000 + state_pension: 12_500 employment_income: 7_000 # 20 hours a week, so remunerative work (reg 6) and the reg 31 # condition for deducting childcare charges are met. @@ -207,13 +208,10 @@ benunits: benunit: members: [person1, person2] - # Reg 27(4)(c)(i): the higher amount disregarded for a lone parent's - # earnings, £25 a week (Sch 4 para 2) against Pension Credit's £20 - # (SPC Regs 2002 Sch VI para 1); and (d): the additional £17.10 (Sch - # 4 para 9(1)) for a lone parent working 16 hours or more whose net - # earnings cover the disregards and childcare. Pension Credit income - # in the model disregards no earnings, so the whole of both is - # deducted: (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. + # The lone parent's £25 a week (Sch 4 para 2) and the additional + # £17.10 (Sch 4 para 9(1)) for a lone parent working 16 hours or more + # whose net earnings cover the disregards and childcare: + # (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. housing_benefit_applicable_income_disregard: 2_189.20 households: household: @@ -222,20 +220,57 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 = 16,914. - pension_credit_income: 16_914 + # The lone parent's 20 pound Pension Credit disregard (SPC Regs 2002 Sch + # VI para 1): 20 x 52 = 1,040. + pension_credit_earnings_disregard: 1_040 + # (19,500 - 12,570) x 0.2 = 1,386; 19,500 - 1,386 - 1,040 = 17,074. + pension_credit_income: 17_074 # 12,376 + 3,638.96 = 16,014.96. minimum_guarantee: 16_014.96 guarantee_credit: 0 - # 933.82 - 0.4 x (16,914 - 16,014.96) = 933.82 - 359.62 = 574.20. - savings_credit: 574.20 + # 933.82 - 0.4 x (17,074 - 16,014.96) = 933.82 - 423.62 = 510.20. + savings_credit: 510.20 in_receipt_of_savings_credit_only: true # Reg 27(4)(b): 2,600 of childcare (£50 a week), below the weekly # maximum and the earnings. housing_benefit_applicable_income_childcare_element: 2_600 - # 16,914 + 574.20 - 2,600 - 2,189.20 = 12,699. - housing_benefit_savings_credit_only_income: 12_699 - housing_benefit_applicable_income: 12_699 + # Reg 27(4)(c)(i): the higher amount for a lone parent's earnings, + # 1,300 - 1,040 = 260; (d): the additional 17.10 x 52 = 889.20. + # 17,074 + 510.20 - 2,600 - 260 - 889.20 = 13,835. + housing_benefit_savings_credit_only_income: 13_835 + housing_benefit_applicable_income: 13_835 + +- name: Case 5a, the same lone parent with less State Pension, whose 20 pound Pension Credit disregard brings a guarantee credit and its passport. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 78 + state_pension: 11_000 + employment_income: 7_000 + hours_worked: 1_040 + person2: + age: 8 + childcare_expenses: 2_600 + benunits: + benunit: + members: [person1, person2] + housing_benefit_applicable_income_disregard: 2_189.20 + households: + household: + members: [person1, person2] + rent: 7_800 + tenure_type: RENT_FROM_COUNCIL + savings: 0 + output: + # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 - 1,040 = 15,874. + pension_credit_income: 15_874 + # 16,014.96 - 15,874 = 140.96. + guarantee_credit: 140.96 + in_receipt_of_savings_credit_only: false + # The guarantee credit passports the maximum Housing Benefit: the rent. + housing_benefit: 7_800 - name: Case 6, guarantee credit and savings credit together, the award is not savings credit only and the guarantee credit passport applies. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml index ce4b1ee145..daeb1b21c8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml @@ -445,7 +445,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_based_jsa: [false, true] -- name: A non-dependent adult's own JSA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own JSA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -471,15 +471,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. jsa_remunerative_work_hours: [0, 0, 0] - jsa_income_eligible: false - jsa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_based_jsa: [false, false, false] + jsa_income_eligible: true + jsa_income: 3000 + claimant_or_partner_jsa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_based_jsa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(f)). @@ -508,3 +506,38 @@ jsa_income_eligible: false jsa_income: 0 income_support_eligible: true + +- name: Issue 2395 couple closure leaves a separate outside JSA award open + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + is_parent: true + child_tax_credit_reported: 2_000 + partner: + age: 30 + is_claimant_or_partner: true + is_parent: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + child: + age: 5 + is_claimant_or_partner: false + benunits: + unit: + members: [claimant, partner, outside, child] + jsa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside, child] + output: + legacy_benefits_closed: true + jsa_income: 5_000 + claimant_or_partner_jsa_income: 0 + is_on_income_based_jsa: [false, false, true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml index 66b8c4e5e0..2905d77c7b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml @@ -722,8 +722,8 @@ # "Couple" in the cap includes a single claimant's partner (reg. 78(2)). # 11,000 a year (916.67 a month) exceeds 16 hours at the national living # wage of 12.71 (NMW Regs 2015 reg. 4 from 1 April 2026): - # 12.71 x 16 x 52 / 12 = 881.23 a month. The model uses a fixed - # 10,152 a year; 11,000 clears both. + # 12.71 x 16 x 52 / 12 = 881.23, so 881 a month (UC Regs reg. 6(1A)(za)), + # or 10,572 a year. is_uc_benefit_cap_exempt_earnings: true # Housing Benefit has no earnings exception (HB Regs 2006 regs. # 75E-75F); with no working tax credit the HB cap applies. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 99132fcbd3..309195440b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -355,6 +355,8 @@ age: 92 employment_income: 20_000 employer_pension_contributions: 65_000 + # No allowance carried forward, so the whole excess is charged. + unused_pension_annual_allowance: 0 married_couples_allowance: 11_000 would_claim_marriage_allowance: false partner: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml new file mode 100644 index 0000000000..dc6bd9b846 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml @@ -0,0 +1,61 @@ +# The expense input is an already-allowable ITEPA Part 5 Chapter 2 amount. +# No raw-cost or per-employment admissibility is inferred by these cases. +- name: Qualified employment expenses reduce earnings and increase final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + employment_expenses: 600 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 5_400 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 5_400 + uc_earned_income: 5_400 + uc_income_reduction: 2_970 + # Standard allowance 393.45 monthly; no child/housing elements, + # benefit cap reduction or debt deductions. + universal_credit_pre_benefit_cap: 1_751.40 + universal_credit: 1_751.40 + +- name: The same worker without expenses retains the original final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 6_000 + income_tax: 0 + national_insurance: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 6_000 + uc_earned_income: 6_000 + uc_income_reduction: 3_300 + universal_credit_pre_benefit_cap: 1_421.40 + universal_credit: 1_421.40 diff --git a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml index 52d193adeb..791dba5f6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -4,7 +4,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -23,7 +23,7 @@ - name: Case 2, pass holders pay nothing even when they make journeys. period: 2025 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -36,7 +36,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST bus_in_london_trips: 100 @@ -51,7 +51,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST local_bus_trips: 100 @@ -66,7 +66,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 @@ -85,7 +85,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -101,7 +101,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SOUTH_EAST other_local_bus_trips: 100 @@ -116,7 +116,7 @@ period: 2025 absolute_error_margin: 0.01 input: - gov.dft.bus.reference_year: 2025 + gov.dft.bus.benchmark_year: 2025 gov.dft.bus.reference_fares.cap: 2 region: SCOTLAND other_local_bus_trips: 100 @@ -126,3 +126,105 @@ gov.dft.bus.fares.cap: 0 output: person_bus_fare_spending: 200 + +# Default parameters. The yield moves from the FY2024-25 benchmark with DfT's +# BUS0415a fares index, day-weighted over 6 April to 5 April. Quarter-end +# values date from the start of their quarter: FY2024-25 for England outside +# London is (86 * 181.6 + 92 * 185.7 + 92 * 185.9 + 90 * 206.4 + 5 * 210.5) +# / 365 = 190.2282 and FY2025-26 is (86 * 210.5 + 92 * 213.6 + 92 * 214.3 + +# 95 * 217.9557) / 365 = 214.1797. Benchmarks: docs/engineering/bus-fares.md. +- name: Case 9, the 2024-25 benchmark prices journeys at the published yield. + period: 2024 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 100 trips * 1.12466920264 boardings * GBP 1.554716967657. + person_bus_fare_spending: 174.85 + +- name: Case 10, the 2025-26 yield follows the fares index, not a cap term. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + output: + # 174.8542 * 214.1797 / 190.2282 (+12.6%). The cap is GBP 3 all year + # both in the index and in the cap schedule, so nothing is added. + person_bus_fare_spending: 196.87 + +- name: Case 11, London fares follow the flat London index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: LONDON + bus_in_london_trips: 100 + bus_pass_eligible: false + output: + # 100 * 2.369144880275 * GBP 1.017729905895 * 196.1017 / 196.1. + person_bus_fare_spending: 241.12 + +- name: Case 12, Scottish fares follow the Scottish index. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SCOTLAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # FY2024-25 (86 * 207.7 + 92 * 210.9 + 92 * 211.3 + 90 * 211.5 + 5 * 228) + # / 365 = 210.6290; FY2025-26 (86 * 228 + 92 * 228.4 + 92 * 228.4 + 95 * + # 234.6833) / 365 = 229.9411. 100 * 2.145240985363 * GBP 2.599723222144 + # * 229.9411 / 210.6290. + person_bus_fare_spending: 608.84 + +- name: Case 13, Northern Ireland has no fares index, so its yield follows CPI. + period: 2025 + absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + other_local_bus_trips: 100 + bus_pass_eligible: false + output: + # 100 * 1.252853758464 * GBP 2.550929922065 * 1.034 (OBR CPI, 2025 over + # 2024, as the engine's index rounds it: 1.61059 / 1.55763). + person_bus_fare_spending: 330.46 + +# Cap changes use the table of uncapped single fares in +# gov.dft.bus.uncapped_single_fares: 55% of singles at GBP 1.65, 30% at +# GBP 2.21 and 15% at GBP 3.87 at FY2024-25 prices, uprated with OBR CPI +# (FY2025-26 x 1.61059 / 1.55763 = 1.0340). Case 10's passenger has 100 trips +# * 1.12466920264 boardings * 0.7 single share = 78.7268 single boardings, at +# a GBP 196.87 baseline. +- name: Case 14, without a cap in 2025-26 the singles above GBP 3 pay in full. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: .inf + output: + # Only the high band (3.87 * 1.0340 = 4.0016) is above GBP 3: + # 196.87 + 78.7268 * 0.15 * 1.0016. + person_bus_fare_spending: 208.70 + +- name: Case 15, a GBP 2 cap in 2025-26 saves the middle and high bands. + period: 2025 + absolute_error_margin: 0.01 + input: + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.7 + gov.dft.bus.fares.cap: 2 + output: + # Middle band 2.21 * 1.0340 = 2.2851 falls to 2; the high band falls + # from 3 to 2: 196.87 - 78.7268 * (0.30 * 0.2851 + 0.15 * 1). + person_bus_fare_spending: 178.33 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml index ae03b52bdd..11ffa117c5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml @@ -5,15 +5,14 @@ # (reg 55(4)), and the deductions are the person's own relievable pension # contributions and the income tax and primary Class 1 contributions paid "in # respect of the employment" (reg 55(5)). Income the minimum income floor -# treats a person as having does not count (reg 82(4)). The threshold is the -# model's 10,152 a year (issue #1986). Unless stated otherwise, each family is +# treats a person as having does not count (reg 82(4)). The 2026 threshold is +# 10,572 a year (881 a month; issue #2523). Unless stated otherwise, each family is # a single claimant aged 40 outside London with an entered award of 18,000 # before the cap, so the cap of 14,753 takes 3,247 unless an exception applies. # Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 # https://www.legislation.gov.uk/uksi/2013/376/regulation/82 # https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 -# The existing model threshold is retained here; 11,000 and 10,000 also lie -# above and below the 2026 NLW-based threshold of 10,574.72 respectively. +# 11,000 and 10,000 lie above and below it. - name: Statutory sick pay is earned income for the earnings exception period: 2026 @@ -59,7 +58,7 @@ region: NORTH_EAST output: # 3,000 + 8,000 = 11,000, under the personal allowance and the primary - # threshold, so no tax or NI: 11,000 >= 10,152. Without the maternity + # threshold, so no tax or NI: 11,000 >= 10,572. Without the maternity # pay the wages alone, 3,000, are under the threshold. uc_individual_earned_income_before_mif: 11_000 is_uc_benefit_cap_exempt_earnings: true @@ -88,7 +87,7 @@ # covered by the personal allowance, leaving all the tax on the pension. # Reg 55(5)(b) requires tax in respect of employment to be deducted but # does not prescribe this allocation between wages and pension income. - # No NI on 11,000. 11,000 >= 10,152. Netting the 1,686 off the wages + # No NI on 11,000. 11,000 >= 10,572. Netting the 1,686 off the wages # would give 9,314, under the threshold. income_tax: 1_686 uc_income_tax_on_earnings: 0 @@ -114,7 +113,7 @@ region: NORTH_EAST output: # Reg 55(5)(a): 11,000 - 1,000 = 10,000, with no tax or NI, is under - # 10,152. The cap applies: 18,000 - 14,753 = 3,247. + # 10,572. The cap applies: 18,000 - 14,753 = 3,247. uc_individual_earned_income_before_mif: 10_000 is_uc_benefit_cap_exempt_earnings: false is_uc_benefit_cap_exempt: false @@ -158,8 +157,7 @@ region: NORTH_EAST output: # The reform includes basic income in UC earned income, so the shared - # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,152. - # The total also exceeds #1820's 2026 NLW-based earnings threshold. + # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,572. # Counting basic income in this test is new in this PR: main's formula # never counted it. Whether reg 82 should count it is an open question # for #1986 and #1820. The award of 25,000 is above the couple cap diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml index a33fcf4574..082ac4d353 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_specified_benefits.yaml @@ -1106,6 +1106,88 @@ is_uc_benefit_cap_exempt_specified_benefit: false is_housing_benefit_benefit_cap_exempt_specified_benefit: true +- name: A dependent young person's AFIP does not lift the Universal Credit cap, so the award is capped + # For Universal Credit, AFIP is within "attendance allowance" (UC Regs + # 2013 reg 2), and reg 83(1)(c) excepts only where "a claimant is + # receiving" it. The 17-year-old is a qualifying young person, not a + # claimant, so the family's Universal Credit is capped at the London + # family rate of 25,323 a year. AFIP also earns no disabled child + # addition (reg 24(2) lists DLA, PIP and the Scottish equivalents, and + # blindness for the higher rate), so the maximum amount is the standard + # allowance, three child elements and the 30,000 housing costs element. + period: 2026 + input: + people: + parent: + age: 45 + young_person: + age: 17 + current_education: UPPER_SECONDARY + armed_forces_independence_payment: 5_740 + child1: + age: 10 + child2: + age: 8 + benunits: + benunit: + members: [parent, young_person, child1, child2] + households: + household: + members: [parent, young_person, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + is_qualifying_young_person_for_child_benefit: [false, true, false, false] + is_uc_assessed_claimant: [true, false, false, false] + is_uc_benefit_cap_exempt_specified_benefit: false + is_uc_benefit_cap_exempt: false + uc_benefit_cap: 25_323 + uc_disability_elements: 0 + universal_credit_pre_benefit_cap: 46_615.92 + uc_benefit_cap_reduction: 24_561.12 + universal_credit: 22_054.80 + absolute_error_margin: 1 + +- name: A dependent young person's AFIP lifts the Housing Benefit cap, so the award is paid in full + # HB Regs 2006 reg 75F(1)(ea) names "the claimant, the claimant's partner + # or a young person for whom the claimant or the claimant's partner is + # responsible". The same family on legacy Housing Benefit (an existing + # award, no Universal Credit claim) is outside the cap, so its Housing + # Benefit is paid in full. + period: 2026 + input: + people: + parent: + age: 45 + housing_benefit_reported: 10_000 + young_person: + age: 17 + current_education: UPPER_SECONDARY + armed_forces_independence_payment: 5_740 + child1: + age: 10 + child2: + age: 8 + benunits: + benunit: + members: [parent, young_person, child1, child2] + would_claim_uc: false + households: + household: + members: [parent, young_person, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + universal_credit_pre_benefit_cap: 0 + is_housing_benefit_benefit_cap_exempt_specified_benefit: true + is_housing_benefit_benefit_cap_exempt: true + housing_benefit_benefit_cap_reduction: 0 + housing_benefit_pre_benefit_cap: 30_000 + housing_benefit: 30_000 + absolute_error_margin: 1 + - name: A 16-year-old out of education with armed forces independence payment does not lift the cap period: 2025 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml new file mode 100644 index 0000000000..dacaa41c45 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_uc_benefit_cap_exempt_earnings.yaml @@ -0,0 +1,229 @@ +# Universal Credit benefit cap earnings exception, Universal Credit +# Regulations 2013 reg. 82. +# https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.gov.uk/benefit-cap/when-youre-not-affected + +# --- Earnings exception: only tax on the earnings is deducted (reg. 55(5)) --- + +- name: Unearned income does not reduce net earnings for the earnings exception + # Regulation 55(5) deducts the income tax paid "in respect of the + # employment", not the claimant's whole income tax liability. Property + # income of 40,000 pushes total income tax to over 8,000, but the tax + # attributable to the 13,000 of earnings is under 100, so net earnings stay + # well above the 846/month threshold and the exception applies. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 13_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_uc_benefit_cap_exempt_earnings: true + is_uc_benefit_cap_exempt: true + +- name: Earnings below the threshold do not except, even with large unearned income + # The same unearned income with earnings of 8,000 leaves net earnings below + # 846 x 12 = 10,152, so the exception does not apply. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 8_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_uc_benefit_cap_exempt_earnings: false + +- name: A couple's earnings are combined for the earnings exception + # Reg. 82(1)(a) applies to "the claimant's and their partner's combined + # earned income". Neither partner alone reaches 10,152; together they do. + period: 2025 + input: + people: + person1: + age: 35 + employment_income: 6_000 + person2: + age: 35 + employment_income: 6_000 + benunits: + benunit: + members: [person1, person2] + output: + is_uc_benefit_cap_exempt_earnings: true + +- name: Relievable pension contributions reduce earned income + # Reg. 55(5)(a) deducts relievable pension contributions from employed + # earnings, so a contribution that takes net earnings below the threshold + # removes the exception. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 11_000 + employee_pension_contributions: 1_500 + benunits: + benunit: + members: [person] + output: + is_uc_benefit_cap_exempt_earnings: false + +- name: Tax on private pension income does not reduce earned income for the earnings exception + # Reg. 55(5)(b) deducts only income tax paid "in respect of the + # employment". The 5,000 private pension uses up the personal allowance, + # so earned_income_tax (tax on non-savings, non-dividend income) is 546, + # but none of it is in respect of the 10,300 of wages. Earned income is + # 10,300, which clears 846 x 12 = 10,152, so the cap does not reduce the + # family's Universal Credit. + period: 2025 + input: + people: + parent: + age: 35 + employment_income: 10_300 + private_pension_income: 5_000 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [parent, child1, child2] + households: + household: + members: [parent, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + earned_income_tax: [546, 0, 0] + uc_income_tax_on_earnings: [0, 0, 0] + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 33_876.72 + absolute_error_margin: 1 + +- name: One partner's pension contributions do not reduce the other partner's earnings + # Reg. 82(1)(a) adds the couple's earned income, and each person's earned + # income is their own earnings less their own deductions. The + # self-employed partner's 100 of profit less 3,000 of relievable pension + # contributions is nil, not minus 2,900 (reg. 57(2), steps 3 and 4). + # Combined earned income is 10,600, which clears 881 x 12 = 10,572. + period: 2026 + input: + people: + employee: + age: 35 + employment_income: 10_600 + self_employed: + age: 35 + self_employment_income: 100 + personal_pension_contributions: 3_000 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [employee, self_employed, child1, child2] + households: + household: + members: [employee, self_employed, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + uc_individual_earned_income_before_mif: [10_600, 0, 0, 0] + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 31_074.08 + absolute_error_margin: 1 + +- name: Pension contributions after 75 are not deducted from earned income + # Contributions paid after the individual reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct them. + # The mixed-age couple's Universal Credit award is capped like any other + # (reg. 3(2)(a); no age exception reaches it), and the older partner's + # 11,000 of earnings clears 881 x 12 = 10,572 before and after the + # 1,000 contribution. + period: 2026 + input: + people: + older_partner: + age: 76 + employment_income: 11_000 + employee_pension_contributions: 1_000 + younger_partner: + age: 40 + child1: + age: 5 + child2: + age: 8 + benunits: + benunit: + members: [older_partner, younger_partner, child1, child2] + households: + household: + members: [older_partner, younger_partner, child1, child2] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + output: + is_uc_assessed_claimant: [true, true, false, false] + housing_benefit_pension_age_regulations_apply: false + uc_individual_earned_income_before_mif: [11_000, 0, 0, 0] + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + universal_credit: 42_066.40 + absolute_error_margin: 1 + +# --- Earnings threshold follows the national living wage, not a fixed 10,152 --- + +- name: The 2025-26 earnings threshold is 846 a month + period: 2025 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # 10,300 clears 846 x 12 = 10,152. + is_uc_benefit_cap_exempt_earnings: true + +- name: The 2026-27 earnings threshold rises to 881 a month + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # The same earnings fall short of 881 x 12 = 10,572. + is_uc_benefit_cap_exempt_earnings: false + +- name: Earnings above the 2026-27 threshold except from the cap + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_600 + benunits: + benunit: + members: [person] + output: + is_uc_benefit_cap_exempt_earnings: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml new file mode 100644 index 0000000000..17e305f5e4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml @@ -0,0 +1,110 @@ +# The Universal Credit benefit cap's earnings threshold (UC Regs 2013 reg +# 82(1)(a)). From 1 April 2017 (SI 2017/138) it is the pay for 16 hours a week +# at the national living wage (NMW Regs 2015 reg 4), times 52 and divided by +# 12, with any fraction of a pound disregarded (reg 6(1A)(za)). Before then it +# was a fixed 430 a month. The model compares a year's earned income with +# twelve times the monthly threshold. +# Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.legislation.gov.uk/uksi/2013/376/regulation/6 +# https://www.legislation.gov.uk/uksi/2017/138/made +# +# The boundary cases are a single claimant aged 40 outside London with an +# entered award of 18,000 before the cap. Their earnings are under the +# personal allowance and the primary threshold, so no tax or NI comes off, and +# the cap of 14,753 takes 3,247 unless the exception applies. + +- name: Before April 2017 the threshold is a fixed 430 a month + period: 2016 + output: + # 430 x 12 = 5,160. + uc_benefit_cap_earnings_exception_threshold: 5_160 + +- name: The threshold in 2017-18 is 16 hours at the 7.50 national living wage + period: 2017 + output: + # 16 x 7.50 x 52 / 12 = 520.00 a month; x 12 = 6,240. + uc_benefit_cap_earnings_exception_threshold: 6_240 + +- name: The threshold in 2023-24 is 16 hours at the 10.42 national living wage + period: 2023 + output: + # 16 x 10.42 x 52 / 12 = 722.45, disregarding the fraction 722 a month; + # x 12 = 8,664. + uc_benefit_cap_earnings_exception_threshold: 8_664 + +- name: The threshold in 2024-25 is 16 hours at the 11.44 national living wage + period: 2024 + output: + # 16 x 11.44 x 52 / 12 = 793.17, so 793 a month; x 12 = 9,516. + uc_benefit_cap_earnings_exception_threshold: 9_516 + +- name: The threshold in 2025-26 is 16 hours at the 12.21 national living wage + period: 2025 + output: + # 16 x 12.21 x 52 / 12 = 846.56, so 846 a month; x 12 = 10,152. + uc_benefit_cap_earnings_exception_threshold: 10_152 + +- name: The threshold in 2026-27 is 16 hours at the 12.71 national living wage + period: 2026 + output: + # 16 x 12.71 x 52 / 12 = 881.23, so 881 a month; x 12 = 10,572. + uc_benefit_cap_earnings_exception_threshold: 10_572 + +- name: Earnings equal to the 2024-25 threshold lift the cap + period: 2024 + input: + age: 40 + employment_income: 9_516 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 9_516 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings a pound under the 2024-25 threshold leave the cap in place + period: 2024 + input: + age: 40 + employment_income: 9_515 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 + +- name: Earnings between the 2024-25 threshold and the 2025-26 figure lift the cap in 2024-25 + period: 2024 + input: + age: 40 + employment_income: 10_000 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,000 is at least 9,516. A threshold of 10,152 would leave the cap. + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings equal to the 2026-27 threshold lift the cap + period: 2026 + input: + age: 40 + employment_income: 10_572 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 10_572 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings between the 2025-26 figure and the 2026-27 threshold leave the cap in 2026-27 + period: 2026 + input: + age: 40 + employment_income: 10_571 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,571 is under 10,572, although it is above 10,152. + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml index 0f37b40de8..53e746bdf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml @@ -89,8 +89,8 @@ working_tax_credit: 0 output: # 2025-26: income tax 20% x (15,000 - 12,570) = 486; Class 1 NI 8% x - # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the model's - # 10,152 a year (846 a month). + # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the 2025-26 + # threshold of 10,152 a year (846 a month). is_uc_benefit_cap_exempt_earnings: true is_uc_benefit_cap_exempt: true is_housing_benefit_benefit_cap_exempt: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml index 462cdd1fca..a54bf6b739 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -160,9 +160,13 @@ is_claimant_or_partner: [true, true] # SPCA 2002 s.5: gross = 5,000 + 1,000 + 100 + 2,000 + 500 + 200 # + 104 = 8,904; deductions = 100 + 50 + 25 + 50% * 300 = 325. - pension_credit_income: 8_579 + # Sch VI para 3 (both carers): 20 pounds a week, capped at net earnings + # (claimant 1,000 - 100 tax - 100 half contributions = 800; partner + # 500 - 25 NI - 50 tax - 50 = 375; 1,175), so 1,040 is disregarded: + # 8,904 - 325 - 1,040. + pension_credit_income: 7_539 # Reg.9: remove the claimant's 100 JSA and partner's 200 ESA. - savings_credit_income: 8_279 + savings_credit_income: 7_239 # Sch.I para.4 and the 2026 carer addition: 2 * 48.15 * 52 = 5,007.60. carer_minimum_guarantee_addition: 5_007.60 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml index 2b8e58ff0e..9f9456d020 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml @@ -39,12 +39,11 @@ # credit (SPCA 2002 s.3(1)(a)). The 2018-19 and 2019-20 couples have no # income, so no savings credit either. The Housing Benefit cases' couple is # aged 80, so savings credit applies to it. The grandchild is 10 (11 in -# 2025-26), born before 6 April 2017. Pension Credit earnings are entered net -# of the SPC Regs Sch VI para 5 disregard (£10 a week for a couple), which -# the model does not apply. £10,000 of savings gives no Pension Credit tariff -# income, and £300 of interest uses up the £300 step-one disregard of tax -# credit income (SI 2002/2006 reg 3). No one pays income tax or National -# Insurance. +# 2025-26), born before 6 April 2017. Employment income is gross: the model +# applies the SPC Regs Sch VI para 5 disregard (£10 a week for a couple) +# once. £10,000 of savings gives no Pension Credit tariff income, and £300 +# of interest uses up the £300 step-one disregard of tax credit income +# (SI 2002/2006 reg 3). No one pays income tax or National Insurance. - name: A Pension Credit couple awarded CTC and WTC gets no child amount # Tax credit income: State Pension 8,000 + interest (300 - 300) + earnings @@ -83,7 +82,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 # 2,600 - 10 x 52 + # Gross wages are 2,600; Sch VI deducts 10 x 52, leaving 2,080. CTC_family_element: 545 households: household: @@ -125,7 +124,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages are 2,600; Sch VI deducts 520, leaving 2,080 once. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -166,7 +165,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # As in the first case, gross wages 2,600 become 2,080 after Sch VI. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -213,7 +212,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_980 # 3,500 - 10 x 52 + # Gross wages 3,500 become 2,980 after Sch VI, even with no PC paid. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -254,7 +253,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages 2,600 less one Sch VI disregard of 520 give 2,080. has_tax_credit_award: true households: household: @@ -407,7 +406,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Tax-credit closure does not alter gross wages 2,600 less Sch VI 520. standard_minimum_guarantee: 18_023.20 # 346.60 x 52 would_claim_uc_at_legacy_closure: false households: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml new file mode 100644 index 0000000000..f3f11f8954 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -0,0 +1,676 @@ +# Expected values are hand-computed from SPC Regs 2002 Sch VI (paras 1, 3, 4, +# 4A and 5): weekly amounts times 52 weeks, capped at the earnings. + +- name: A single claimant with earnings has 5 pounds a week disregarded (para 5(a)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: A couple with earnings has 10 pounds a week disregarded (para 5(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: A claimant on attendance allowance has 20 pounds a week disregarded (para 4(1)(a)(iii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A partner's personal independence payment qualifies the couple (para 4(1)(a)(viii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + pip_dl: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A carer satisfying Sch I para 4 has 20 pounds a week disregarded (para 3). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A lone parent has 20 pounds a week disregarded (para 1). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + child: + age: 10 + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A claimant certified blind has 20 pounds a week disregarded (para 4(1)(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_blind: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: 20 pounds is the most disregarded when both partners qualify (para 4A). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + partner: + age: 70 + employment_income: 2000 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard never exceeds the earnings. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 100 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 100 + +- name: Self-employed earnings get the same disregard. + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Without earnings there is nothing to disregard. + period: 2024 + input: + people: + claimant: + age: 70 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 0 + +- name: A dependant child's disability benefit does not qualify a couple for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + child: + age: 10 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: Income-related ESA, a benefit-unit award, qualifies for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + esa_income: 1000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard comes off Pension Credit income and raises the guarantee credit. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + state_pension: 9000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # minimum guarantee 218.15 a week in 2024-25 (SI 2024/242) + pension_credit_income: 9_000 + 2_000 - 5 * 52 + guarantee_credit: 218.15 * 52 - (9_000 + 2_000 - 5 * 52) + +- name: A taxpayer's disregard is capped at net earnings, so earnings below the disregard leave Pension Credit income unchanged (reg 17(9), 17(10)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Income tax (16,040 - 12,570) x 20% = 694, against 486 without the + # earnings (next case), so tax on the earnings is 208 and net earnings are + # 832; the 20 pound (attendance allowance) disregard of 1,040 is capped at + # 832. + pension_credit_earnings_disregard: 832 + # 16,040 - 694 - 832, the same as with no earnings (next case). + pension_credit_income: 14_514 + +- name: The same claimant with no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # 15,000 - (15,000 - 12,570) x 20% + pension_credit_earnings_disregard: 0 + pension_credit_income: 14_514 + +- name: A dependant's caring does not qualify a couple for the higher disregard (Sch I para 4 is the claimant's or partner's). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2_000 + partner: + age: 70 + child: + age: 17 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: A Scottish taxpayer's tax on earnings is the income tax the earnings add on the Scottish schedule, so Pension Credit income does not fall with earnings. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + employment_income: 1_040 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + # The 1,040 of earnings add 210.89 of income tax (they cross into the + # Scottish intermediate band), so net earnings are 829.11 and the 20 pound + # disregard of 1,040 is capped there; income is as with no earnings (next). + income_tax: [3_016.62, 0] + pension_credit_earnings_disregard: 829.11 + pension_credit_income: 25_194.27 + +- name: The same Scottish couple with no earnings has the same Pension Credit income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + income_tax: [2_805.73, 0] + pension_credit_earnings_disregard: 0 + pension_credit_income: 25_194.27 + +- name: Caring hours without entitlement to carer's allowance do not qualify for the 20 pound disregard (Sch VI para 3 needs Sch I para 4). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Entitlement to carer's allowance qualifies for the 20 pound disregard. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: Tax on earnings includes the tax they add on savings income, so Pension Credit income does not fall with earnings. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Non-savings tax: (12,000 + 1,040 - 12,570) x 20% = 94. + # Those taxable non-savings earnings reduce the savings starting-rate + # band to 5,000 - 470 = 4,530. Savings tax is + # (12,000 - 4,530 - 1,000 PSA) x 20% = 1,294, giving total tax 1,388. + # Without wages tax is 1,086 (next case), so employment tax is 302 and + # net earnings/disregard are 1,040 - 302 = 738, below the 1,040 cap. + # PC counts deemed capital income, not interest: 12,000 + 1,040 - 1,388 + # - 738 = 10,914. HMRC: /apply-tax-free-interest-on-savings/how-much-is-tax-free. + income_tax: 1_388 + pension_credit_employment_income_tax: 302 + pension_credit_earnings_disregard: 738 + pension_credit_income: 10_914 + +- name: The same claimant with savings and no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_tax: 1_086 + pension_credit_earnings_disregard: 0 + # 12,000 - 1,086. + pension_credit_income: 10_914 + +- name: Pension contribution relief already available without earnings does not reduce tax attributed to earnings. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + employment_income: 400 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Relief is available on these 300 of contributions even without wages + # (FA 2004 s.190's 3,600 basic amount, age under 75). With wages tax is + # (15,400 - 12,570 - 300) x 20% = 506; without, it is 426 (next case). + # Employment tax is 80, so net earnings/disregard are + # 400 - 80 - 300 / 2 = 170, under the 5 pound cap of 260. + income_tax: 506 + pension_credit_employment_income_tax: 80 + pension_credit_earnings_disregard: 170 + # 15,400 - 506 - 150 - 170 = 14,574; the net-earnings floor means PC + # income is 150 more than without wages (next case). + pension_credit_income: 14_574 + +- name: The same claimant with no earnings. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # No wages are needed for relief on the 300 contribution below the + # FA 2004 s.190 basic amount: (15,000 - 12,570 - 300) x 20% = 426. + income_tax: 426 + pension_credit_earnings_disregard: 0 + # 15,000 - 426 - 300 / 2 = 14,424. + pension_credit_income: 14_424 + +- name: A self-employment loss is not set against wages in the disregard (CER 1996 reg 13(12)). + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 400 + self_employment_income: -8_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Net earnings are the 400 of wages; the 5 pound disregard is 260. + pension_credit_earnings_disregard: 260 + pension_credit_income: 400 - 260 + +- name: Self-employed earnings under the personal allowance carry no notional tax, and the tax they actually cause is not deducted (CER 1996 reg 14; SPC reg 17(11)). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + self_employment_income: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The State Pension uses all but 570 of the personal allowance, so the + # profit causes all of the (15,000 - 12,570) x 20% = 486 of income tax. + income_tax: 486 + pension_credit_self_employment_income_tax: 486 + # Notional tax at the basic rate on the profit less the personal + # allowance alone is nil; Class 4 is not payable over State Pension age. + pension_credit_self_employment_deductions: 0 + pension_credit_earnings_disregard: 260 + # 15,000 - (486 - 486 + 0) - 260. + pension_credit_income: 14_740 + +- name: Profit within the trading allowance is tax-free and fully disregarded up to the 20 pound limit. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + self_employment_income: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The 1,000 trading allowance relieves the profit, so income tax is the + # (15,000 - 12,570) x 20% = 486 on the pensions. + income_tax: 486 + pension_credit_self_employment_income_tax: 0 + pension_credit_earnings_disregard: 1_000 + # 16,000 - 486 - 1,000, the same as with no profit: 15,000 - 486. + pension_credit_income: 14_514 + +- name: A Scottish self-employed earner's notional tax is at the Scottish basic rate, with main-rate Class 4 under State Pension age. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: SCOTLAND + output: + # (20,000 - 12,570) x 20% = 1,486, not the 1,457.73 of income tax on the + # Scottish starter, basic and intermediate bands; Class 4 is + # (20,000 - 12,570) x 6% = 445.80. + income_tax: 1_457.73 + pension_credit_self_employment_deductions: 1_486 + 445.80 + +- name: Before April 2024 the notional contributions include Class 2 (CER 1996 reg 14(2)(a)). + period: 2023 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2 3.45 x 52 = 179.40. + pension_credit_self_employment_deductions: 1_486 + 668.70 + 179.40 + +- name: The notional tax deducts the blind person's allowance with the personal allowance (ITA 2007 Part 3 Chapter 2). + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 20_000 + blind_persons_allowance: 3_070 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570 - 3,070) x 20%. + pension_credit_self_employment_deductions: 872 + +- name: Working tax credit with its disability element qualifies for the 20 pound disregard (Sch VI para 4(1)(a)(vi)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 1_000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disability element counts only with working tax credit in payment. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 0 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index a69dfec245..d98e564feb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -20,7 +20,11 @@ savings: 11_000 output: pension_credit_deemed_income: 104 - pension_credit_income: 343 + # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI disregard: + # the 20 pound para 4(1)(a)(vii) amount (contributory ESA), capped at the + # 129 of net earnings (no tax or NI on them). + pension_credit_earnings_disregard: 129 + pension_credit_income: 214 - name: Interest and dividends are not counted directly in Pension Credit income. period: 2021 @@ -215,6 +219,8 @@ tenure_type: OWNED_OUTRIGHT output: pension_credit_earnings: 10_000 - pension_credit_income: 10_000 - # 238 x 52 = 12,376, less income of 10,000. - pension_credit: 2_376 + # 10,000 less the 5 pound a week earnings disregard (260); the earnings + # are under the personal allowance and the primary threshold. + pension_credit_income: 9_740 + # 238 x 52 = 12,376, less income of 9,740. + pension_credit: 2_636 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index f43589f7f5..cab1abeb8d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -25,3 +25,43 @@ minimum_guarantee: 12_376 output: savings_credit: 684.216 + + +# Explicit SI 2019/37 art.4 saving INPUT CONTRACT, not an inference from current +# receipt or proof of the separate 2016 Savings Credit continuity conditions. +# Current 2026 model-rate premises: married couple/default Marriage Allowance +# of 1,260 gives tax (20,000-12,570-1,260)*0.2=1,234 and income 18,766. +# Guarantee: 363.25*52=18,889; threshold: 329.75*52=17,147. +# SC=min(0.6*(18,889-17,147),0.6*(18,766-17,147))=971.40; GC=123. +# This hand control is not a new external audit of 2026 tax or uprating rates. +- name: Protected mixed-age couple keeps the computed Savings Credit component + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + is_claimant_or_partner: true + state_pension: 20_000 + person2: + age: 60 + is_claimant_or_partner: true + benunits: + benunit: + members: [person1, person2] + has_mixed_age_couple_pension_credit_saving: true + would_claim_pc: true + households: + household: + members: [person1, person2] + country: ENGLAND + savings: 0 + output: + income_tax: [1_234, 0] + pension_credit_income: 18_766 + savings_credit_income: 18_766 + standard_minimum_guarantee: 18_889 + savings_credit: 971.40 + guarantee_credit: 123 + pension_credit_entitlement: 1_094.40 + pension_credit: 1_094.40 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml new file mode 100644 index 0000000000..2779da57dc --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pip_points.yaml @@ -0,0 +1,129 @@ +- name: PIP daily living — zero points yields no award + period: 2025 + input: + pip_dl_points: 0 + pip_m_points: 0 + output: + pip_dl_category: NONE + pip_m_category: NONE + pip_dl: 0 + pip_m: 0 + +- name: PIP daily living — 7 points is below the standard threshold + period: 2025 + input: + pip_dl_points: 7 + output: + pip_dl_category: NONE + pip_dl: 0 + +- name: PIP daily living — 8 points hits the standard threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 8 + output: + pip_dl_category: STANDARD + pip_dl: 3842.80 + +- name: PIP daily living — 11 points stays at standard + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 11 + output: + pip_dl_category: STANDARD + pip_dl: 3842.80 + +- name: PIP daily living — 12 points hits the enhanced threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 12 + output: + pip_dl_category: ENHANCED + pip_dl: 5740.80 + +- name: PIP mobility — 8 points hits the standard threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_m_points: 8 + output: + pip_m_category: STANDARD + pip_m: 1518.40 + +- name: PIP mobility — 12 points hits the enhanced threshold + period: 2025 + absolute_error_margin: 0.05 + input: + pip_m_points: 12 + output: + pip_m_category: ENHANCED + pip_m: 4006.60 + +- name: PIP — input category overrides points-derived category + period: 2025 + absolute_error_margin: 0.05 + input: + pip_dl_points: 0 + pip_dl_category: ENHANCED + output: + pip_dl_category: ENHANCED + pip_dl: 5740.80 + +- name: PIP — categories supplied for an earlier year carry forward without points + period: 2026 + input: + pip_dl_category: + 2025: ENHANCED + pip_m_category: + 2025: STANDARD + output: + pip_dl_category: ENHANCED + pip_m_category: STANDARD + +- name: PIP — points supplied for the year replace an earlier year's category + period: 2026 + input: + pip_dl_category: + 2025: ENHANCED + pip_dl_points: + 2026: 8 + pip_m_category: + 2025: STANDARD + pip_m_points: + 2026: 0 + output: + pip_dl_category: STANDARD + pip_m_category: NONE + +- name: PIP — points supplied after an earlier category carry forward and win + period: 2026 + input: + pip_dl_category: + 2024: STANDARD + pip_dl_points: + 2025: 12 + pip_m_category: + 2024: ENHANCED + pip_m_points: + 2025: 7 + output: + pip_dl_category: ENHANCED + pip_m_category: NONE + +- name: PIP — a category supplied after earlier points carries forward and wins + period: 2026 + input: + pip_dl_points: + 2024: 12 + pip_dl_category: + 2025: NONE + pip_m_points: + 2024: 0 + pip_m_category: + 2025: ENHANCED + output: + pip_dl_category: NONE + pip_m_category: ENHANCED diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index 8de54538e5..5bbd3d5598 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -31,8 +31,9 @@ # Pension Credit tariff income (SPC Regs 2002 reg 15(6)), and savings interest # is not Pension Credit income. # -# Pension Credit earnings are entered net of the SPC Regs 2002 Sch VI para 5 -# disregard (£5 a week single, £10 a couple), which the model does not apply. +# Employment income is gross. The model applies the SPC Regs 2002 Sch VI +# para 5 disregard once (£5 a week single, £10 a couple); do not pre-net a +# pension_credit_earnings input by that amount. # No case has income tax: each person's income is within the personal # allowance. @@ -57,7 +58,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 # 2,600 - 5 x 52 households: household: members: [pensioner] @@ -92,7 +92,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -124,7 +123,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -153,7 +151,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] @@ -182,7 +179,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 would_claim_pc: false households: household: @@ -231,7 +227,6 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_480 # 3,000 - 10 x 52 CTC_family_element: 545 households: household: @@ -277,7 +272,6 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_480 CTC_family_element: 0 working_tax_credit_run_on: false extended_childcare_entitlement_eligible: false @@ -374,7 +368,6 @@ members: [older, younger] would_claim_uc: false has_mixed_age_couple_pension_credit_saving: true - pension_credit_earnings: 2_080 # 2,600 - 10 x 52 households: household: members: [older, younger] @@ -409,7 +402,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 standard_minimum_guarantee: 11_809.20 households: household: @@ -440,7 +432,6 @@ benunits: benunit: members: [pensioner] - pension_credit_earnings: 2_340 households: household: members: [pensioner] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml new file mode 100644 index 0000000000..2f4f43bbfb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml @@ -0,0 +1,147 @@ +[ + { + "name": "Case 1, outside closed report leaves the couple open.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "esa_income_reported": 5000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + true + ], + "legacy_benefits_closed": false + } + }, + { + "name": "Case 2, couple closure leaves the outside award open.", + "period": 2025, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "child_tax_credit_reported": 2000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "esa_income_reported": 5000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + true, + true, + false + ], + "legacy_benefits_closed": true + } + }, + { + "name": "Case 3, outside Pension Credit age exception uses their own input.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000, + "has_attained_state_pension_credit_qualifying_age": true, + "is_SP_age": false + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + false + ], + "legacy_benefits_closed": false + } + } +] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml index 9bb761603c..7627060033 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -104,7 +104,7 @@ uc_trading_losses_set_against_profits: 5_000 uc_individual_earned_income_before_mif: 7_000 uc_individual_earned_income: 20_000 - # Actual £7,000 is below £10,152, even though the MIF is £20,000. + # Actual £7,000 is below £10,572, even though the MIF is £20,000. is_uc_benefit_cap_exempt_earnings: false - name: Company earnings without the loss exceed the cap threshold with the same deductions diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml new file mode 100644 index 0000000000..9bc38acd25 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml @@ -0,0 +1,176 @@ +# Gains are the top slice of income (TCGA 1992 s. 1H). The model splits gains +# above the basic rate band at the income tax higher rate limit so reforms can +# set an additional CGT rate; the law has none, and the two rates are equal. +# The split stacks gains on top of taxable income, as income tax does: the +# higher band left for gains is the higher rate limit less taxable income or +# the basic rate limit, whichever is higher. +# +# 2025-26: basic rate limit 37,700, higher rate limit 125,140, annual exempt +# amount 3,000, CGT rates 18% and 24%. allowances is set to 0 so taxable income +# equals net_income. + +- name: Additional rate reform, income below the basic rate limit. + # Taxable income 20,000 leaves 17,700 of basic rate band. 150,000 - 3,000 = + # 147,000: 17,700 x 0.18 = 3,186; the higher band left is 125,140 - 37,700 + # = 87,440, x 0.24 = 20,985.60; the rest, 41,860 x 0.45 = 18,837. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 20_000 + allowances: 0 + capital_gains: 150_000 + output: + capital_gains_tax: 43_008.60 + +- name: Additional rate reform, income between the basic and higher rate limits. + # Taxable income 100,000 uses the basic rate band. The higher band left is + # 125,140 - 100,000 = 25,140, x 0.24 = 6,033.60; the rest of the 97,000, + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Additional rate reform, income above the higher rate limit. + # Taxable income 200,000 is above 125,140, so all 47,000 of gains after the + # annual exempt amount are in the additional band: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 + +- name: Higher rate reform, income above the higher rate limit. + # No higher band is left, so a higher CGT rate of 45% does not apply: + # 47,000 x 0.24 at the unchanged additional rate. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Higher rate reform, income between the basic and higher rate limits. + # 25,140 x 0.45 = 11,313 in the higher band left; 71,860 x 0.24 = 17,246.40. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 28_559.40 + +- name: Residential property additional rate reform, income between the limits. + # The same split applies to each schedule: 25,140 x 0.24 = 6,033.60 and + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.residential_property.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_residential_property: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Carried interest additional rate reform, income between the limits. + # Carried interest is 32% in both bands from 2025-26: 25,140 x 0.32 = + # 8,044.80 in the higher band left and 71,860 x 0.45 = 32,337 above it. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.carried_interest.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_carried_interest: 100_000 + output: + capital_gains_tax: 40_381.80 + +- name: Current law, income above the higher rate limit. + # The higher and additional rates are both 24%: 47,000 x 0.24. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Current law, income between the basic and higher rate limits. + # 97,000 x 0.24 whatever the split. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 23_280 + +- name: Additional rate reform, 200,000 of earnings and 50,000 of gains. + # The personal allowance is fully withdrawn at this income, so taxable + # income is 200,000, above the higher rate limit: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + employment_income: 200_000 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 + +- name: Additional rate reform, main and residential gains, exemption on main gains. + # Taxable income 200,000 is above the higher rate limit, so all gains are + # above the higher rate band. Main gains are at the reformed 45% additional + # rate and residential property gains at their unchanged 24%, so the 3,000 + # annual exempt amount is most beneficial against main gains (TCGA 1992 + # s. 1K(5)): 25,000 x 0.24 = 6,000 and (25,000 - 3,000) x 0.45 = 9,900. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + age: 45 + employment_income: 200_000 + capital_gains: 50_000 + capital_gains_residential_property: 25_000 + output: + capital_gains_tax: 15_900 + +- name: Additional rate reform, exemption follows the rate as main gains cross the higher rate limit. + # The personal allowance is 12,570 - (124,000 - 100,000) / 2 = 570, so + # taxable income is 123,430 and 125,140 - 123,430 = 1,710 of higher band is + # left. Main gains fill it first: 1,710 of the 2,000 main gains at 24% and + # 290 at the reformed 45%. Carried interest is at 32% in either band. The + # exemption saves 45% on the top 290 of main gains, then 32% on carried + # interest rather than 24% on the rest of the main gains (TCGA 1992 + # s. 1K(5)): 1,710 x 0.24 + (25,000 - 2,710) x 0.32 = 7,543.20, not 7,680 + # from exempting all the main gains first. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + gov.simulation.capital_gains_responses.elasticity: 0 + age: 45 + employment_income: 124_000 + capital_gains: 27_000 + capital_gains_carried_interest: 25_000 + output: + capital_gains_tax: 7_543.20 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml new file mode 100644 index 0000000000..9f5ce9390c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bracketized_liability/tax_band_historical.yaml @@ -0,0 +1,222 @@ +# ITA 2007 s.10(2), (3), (3A): equality remains in the lower band. +# FA 2015 s.4, as amended: basic limits 32,000 (2016-17), 33,500 (2017-18). +# FA 2015 s.5, as amended: personal allowances 11,000 and 11,500. +# ITA 2007 s.55B(2)(b): a higher-rate recipient cannot receive the transfer. +# FA 2016 s.1(2) and FA 2017 s.2: basic/higher rates 20%/40%. +# Annual labels use fiscal-year parameters but formula dispatch at 1 January. +# Model-resident married adults below MCA age, no other income/reliefs, +# and default claimed-election/take-up assumptions. This does not audit every +# historical statutory election or devolved-rate condition. + +- name: Case 1, higher-rate spouse cannot receive Marriage Allowance in 2016. + # 32000 * 20% + (60,000 - 11000 - 32000) * 40% = 13200. + period: 2016 + input: + people: + person1: + age: 40 + employment_income: 60_000 + person2: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [person1, person2] + is_married: true + households: + household: + members: [person1, person2] + country: ENGLAND + output: + personal_allowance: [11000, 11000] + net_income: [60_000, 5_000] + tax_band: [HIGHER, NONE] + meets_marriage_allowance_income_conditions: [false, true] + marriage_allowance: [0, 0] + income_tax: [13200, 0] + +- name: Case 2, higher-rate spouse cannot receive Marriage Allowance in 2017. + # 33500 * 20% + (60,000 - 11500 - 33500) * 40% = 12700. + period: 2017 + input: + people: + person1: + age: 40 + employment_income: 60_000 + person2: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [person1, person2] + is_married: true + households: + household: + members: [person1, person2] + country: ENGLAND + output: + personal_allowance: [11500, 11500] + net_income: [60_000, 5_000] + tax_band: [HIGHER, NONE] + meets_marriage_allowance_income_conditions: [false, true] + marriage_allowance: [0, 0] + income_tax: [12700, 0] + +- name: Case 3, the basic-rate limit stays basic. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 43000 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 4, income above the basic-rate limit is higher. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 43000.01 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 5, the later historical basic-rate limit stays basic. + # Classifier unit contract with supplied net income/allowances. + period: 2017 + input: + net_income: 45000 + allowances: 11500 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 6, income above the later basic-rate limit is higher. + # Classifier unit contract with supplied net income/allowances. + period: 2017 + input: + net_income: 45000.01 + allowances: 11500 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 7, the additional-rate limit stays higher. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 150000 + allowances: 0 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 8, the next float32 value above the additional-rate limit is additional. + # Classifier unit contract with supplied net income/allowances. + # 0.015625 is one representable float32 step at 150,000, not a tolerance. + period: 2016 + input: + net_income: 150000.015625 + allowances: 0 + gift_aid_grossed_up: 0 + output: + tax_band: ADDITIONAL + meets_marriage_allowance_income_conditions: false + +- name: Case 9, zero income remains below the allowance. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 0 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: NONE + meets_marriage_allowance_income_conditions: true + +- name: Case 10, income at the allowance remains untaxed. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 11000 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: NONE + meets_marriage_allowance_income_conditions: true + +- name: Case 11, income above the allowance is basic. + # Classifier unit contract with supplied net income/allowances. + period: 2016 + input: + net_income: 11000.01 + allowances: 11000 + gift_aid_grossed_up: 0 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 12, Gift Aid extends the basic-rate limit. + # Classifier unit contract with supplied net income/allowances. + # ITA 2007 s.414(2)(b): grossed-up gift extends both limits by 2,000. + period: 2016 + input: + net_income: 45000 + allowances: 11000 + gift_aid_grossed_up: 2000 + output: + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +- name: Case 13, income above the extended Gift Aid limit is higher. + # Classifier unit contract with supplied net income/allowances. + # ITA 2007 s.414(2)(b): grossed-up gift extends both limits by 2,000. + period: 2016 + input: + net_income: 45000.01 + allowances: 11000 + gift_aid_grossed_up: 2000 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 14, earliest model year uses the actual bracket slots. + # Classifier unit contract with supplied net income/allowances. + # Parameter-consistency control only; no full 2015 rate-law audit. + period: 2015 + input: + net_income: 60000 + allowances: 10600 + gift_aid_grossed_up: 0 + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 15, modern rest-of-UK routing remains unchanged away from a boundary. + # Existing later-method behavior preservation, not a new historical rate audit. + period: 2018 + input: + net_income: 60_000 + allowances: 11_850 + pays_scottish_income_tax: false + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Case 16, modern Scottish routing remains unchanged away from a boundary. + # Existing 2026 parameter/dispatch control: 17,430 falls in the intermediate band. + # This does not audit unrelated Scottish dates or exact-boundary behavior. + period: 2026 + input: + net_income: 30_000 + allowances: 12_570 + pays_scottish_income_tax: true + output: + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml new file mode 100644 index 0000000000..6c9ad649c2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/adjusted_total_income_pension_relief.yaml @@ -0,0 +1,426 @@ +# Ordinary loss-cap ATI (ITA 2007 s.24A(8)), preserving the model's own earnings base. +# FA 2004 ss.190(3),191(7): only relief at source can give the basic uplift above +# relevant earnings. Existing inputs treat gross personal contributions as RAS +# and reported employee contributions as net pay; no new scheme-input model. +# Primary 2026 control: wages2,000 + dividends250,000 - relievednetpay2,000 +# => ATI250,000; cap62,500; ANI187,500. All rates/thresholds are unchanged +# model controls, not a new rate audit. Final dividend tax is: +# (37,700-500)*.1075 + (125,140-37,700)*.3575 + (187,500-125,140)*.3935 +# =59,797.46. No eligibility, tax base, relief or final tax is supplied as input. + +- name: Net pay within earnings preserves the final tax control + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 2000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: &id001 + income_tax: 59797.46 + employment_income: 2000 + pension_contributions_relief: 2000 + adjusted_total_income: 250000 + income_tax_relief_cap: 62500 + trade_loss_relief_against_general_income: 62500 + net_income: 189500 + adjusted_net_income: 187500 + personal_allowance: 0 + personal_pension_contributions_tax: 0 +- name: Pure net pay above earnings cannot use the RAS basic uplift + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: *id001 +- name: Mixed payments limit the basic uplift to actual gross RAS + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 249400 + income_tax_relief_cap: 62350 + pension_contributions_relief: 2600 +- name: No member contribution leaves adjusted total income unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 252000 + income_tax_relief_cap: 63000 +- name: Relief cannot exceed the member contributions actually paid + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 1000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 251000 + pension_contributions_relief: 1000 +- name: Pure RAS retains relief up to the basic amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 248400 + income_tax_relief_cap: 62100 + pension_contributions_relief: 3600 +- name: Mixed contributions below earnings remain fully relieved + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 1000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250400 + pension_contributions_relief: 1600 +- name: The age 75 boundary excludes member relief + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 75 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + state_pension_reported: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 252000 + pension_contributions_relief: 0 +- name: Earnings above the basic amount retain ordinary net pay relief + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 10000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 5000 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 255000 + pension_contributions_relief: 5000 +- name: ATI retains statutory pay in its own relevant earnings base + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 1000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 4000 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + statutory_maternity_pay: 3000 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250000 + pension_contributions_relief: 4000 +- name: The basic amount remains reformable for actual RAS contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 6000 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount: 5000 + output: + adjusted_total_income: 247000 + pension_contributions_relief: 5000 +- name: Relieved RAS cannot make adjusted total income negative + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 0 + dividend_income: 0 + trading_loss: 0 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 0 + income_tax: 0 +- name: Each person keeps their own RAS and age limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + p0: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + p1: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3000 + personal_pension_contributions: 600 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + p2: + age: 75 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + state_pension_reported: 0 + benunits: + b0: + members: + - p0 + b1: + members: + - p1 + b2: + members: + - p2 + households: + h0: + members: + - p0 + region: LONDON + h1: + members: + - p1 + region: LONDON + h2: + members: + - p2 + region: LONDON + output: + adjusted_total_income: + - 250000 + - 249400 + - 252000 + pension_contributions_relief: + - 2000 + - 2600 + - 0 +- name: ATI first and final tax first give the same corrected cash + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income_before_lsr: 2000 + dividend_income: 250000 + trading_loss: 200000 + employee_pension_contributions_reported: 3600 + personal_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + employer_pension_contributions: 0 + benunits: + family: + members: + - taxpayer + households: + home: + members: + - taxpayer + region: LONDON + output: + adjusted_total_income: 250000 + income_tax_relief_cap: 62500 + adjusted_net_income: 187500 + income_tax: 59797.46 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml new file mode 100644 index 0000000000..a000b8c814 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_full_year_director_2022.yaml @@ -0,0 +1,143 @@ +# Literal annual employee-primary expectations from HMRC's 2022–23 director +# table: PT £11,908, UEL £50,270, category A rates 12.73% and 2.73%. +# Annual model amounts, not interim payroll deductions. All are adults below SPA. +- name: Full-year category A director below the annual primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0 + +- name: Full-year category A director at the annual primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11908 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0 + +- name: Full-year category A director one pound above the primary threshold + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 11909 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 0.1273 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 0.1273 + +- name: Full-year category A director in the main band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 30000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 2303.1116 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 2303.1116 + +- name: Full-year category A director one pound below the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50269 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.3553 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 4883.3553 + +- name: Full-year category A director at the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50270 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 0 + ni_class_1_employee: 4883.4826 + +- name: Full-year category A director one pound above the upper earnings limit + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50271 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 0.0273 + ni_class_1_employee: 4883.5099 + +- name: Full-year category A director above the upper band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 100000 + is_full_year_category_a_director: true + output: + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 1357.629 + ni_class_1_employee: 6241.1116 + +- name: Natural salary household pays director NI and unchanged income tax + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + household_owns_tv: false + employment_income: 30000 + is_full_year_category_a_director: true + would_claim_uc: false + output: + ni_class_1_income: 30000 + ni_liable: true + income_tax: 3486 + ni_class_1_employee: 2303.1116 + national_insurance: 2303.1116 + # Existing calendar-2022 support: council-tax rebate 150 + EBSS 199. + ebr_council_tax_rebate: 150 + ebr_energy_bills_credit: 199 + household_benefits: 349 + tv_licence: 0 + +- name: Natural higher salary household reconciles both director bands and cash + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + household_owns_tv: false + employment_income: 60000 + is_full_year_category_a_director: true + would_claim_uc: false + output: + ni_class_1_income: 60000 + income_tax: 11432 + ni_class_1_employee_primary: 4883.4826 + ni_class_1_employee_additional: 265.629 + ni_class_1_employee: 5149.1116 + national_insurance: 5149.1116 + # Existing calendar-2022 support: council-tax rebate 150 + EBSS 199. + ebr_council_tax_rebate: 150 + ebr_energy_bills_credit: 199 + household_benefits: 349 + tv_licence: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml index 5b36e53d92..6c065272de 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml @@ -91,7 +91,7 @@ # £100 excess * 8% = £8 salary_sacrifice_pension_ni_employee: 100 * 0.08 -- name: Employee NI on full redirected excess +- name: Employee NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,8 +103,9 @@ output: # £1,000 excess * 8% = £80 salary_sacrifice_pension_ni_employee: (3_000 - 2_000) * 0.08 - # Full excess is redirected (no targeted haircut) - salary_sacrifice_returned_to_income: 1_000 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 - name: Broad-base haircut reduces all workers employment income period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml index 4eb0fdf97c..669a26ce24 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml @@ -91,7 +91,7 @@ # £3,000 excess * 15% = £450 salary_sacrifice_pension_ni_employer: (5_000 - 2_000) * 0.15 -- name: Employer NI on full redirected excess +- name: Employer NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,5 +103,6 @@ output: # £1,000 excess * 15% = £150 salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 - # Full excess is redirected (no targeted haircut) - salary_sacrifice_returned_to_income: 1_000 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml new file mode 100644 index 0000000000..ae631ab66c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml @@ -0,0 +1,113 @@ +# The total pension input amount (FA 2004 s. 229) adds each arrangement's +# input amount. A money purchase arrangement's is the contributions paid +# (s. 233); a defined-benefit arrangement's is the increase in the value of +# the member's rights (s. 234), whatever was paid in. defined_benefit_pension_share +# says how much of the workplace contributions (employer, net pay and salary +# sacrifice) go to defined-benefit arrangements; defined_benefit_pension_input_amount +# replaces them, and defaults to them times a ratio of 1. Unused allowance +# brought forward is set to nil where a case tests the charge. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/229 +# https://www.legislation.gov.uk/ukpga/2004/12/section/233 +# https://www.legislation.gov.uk/ukpga/2004/12/section/234 + +- name: Without a scheme type every workplace contribution is money purchase + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + output: + defined_benefit_pension_contributions: 0 + defined_benefit_pension_input_amount: 0 + # 20,000 + 5,000 + 2,000. + pension_contributions_for_annual_allowance: 27_000 + +- name: A defined-benefit member's contributions count at face value by default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + output: + # The employer's 20,000 and the 5,000 of net pay go to the defined-benefit + # scheme; the 2,000 of relief at source is money purchase. + defined_benefit_pension_contributions: 25_000 + defined_benefit_pension_input_amount: 25_000 + pension_contributions_for_annual_allowance: 27_000 + +- name: A supplied defined-benefit input amount replaces the contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 45_000 + output: + # 2,000 of money purchase input plus the 45,000 statement value. + pension_contributions_for_annual_allowance: 47_000 + +- name: Half the workplace contributions go to a defined-benefit arrangement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 0.5 + defined_benefit_pension_input_amount: 30_000 + output: + # Half of 25,000 goes to the defined-benefit scheme. Money purchase input + # is 2,000 + 2,500 of net pay + 10,000 from the employer, plus 30,000. + defined_benefit_pension_contributions: 12_500 + pension_contributions_for_annual_allowance: 44_500 + +- name: From age 75 the defined-benefit input still counts + period: 2026 + absolute_error_margin: 0.01 + input: + age: 76 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 30_000 + output: + # The 2,000 paid personally from 75 is unrelievable and not a money + # purchase input (s. 188(3)(a)); the net pay is in the defined-benefit + # input instead, which is not excluded. + pension_contributions_for_annual_allowance: 30_000 + +- name: The charge falls on the defined-benefit input above the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 80_000 + unused_pension_annual_allowance: 0 + output: + pension_contributions_for_annual_allowance: 80_000 + # Adjusted income 100,000 + 80,000 - 5,000 of member contributions is + # under 260,000, so the allowance is 60,000. + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 20_000 + # Reduced net income is 100,000 - 5,000 - 12,570 = 82,430, in the higher + # rate band, so the 20,000 is charged at 40%. + personal_pension_contributions_tax: 8_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml index 1630f917da..0ed741ac8e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -241,11 +241,11 @@ pension_annual_allowance_threshold_income: 220_000 pension_annual_allowance: 40_000 -# The NICs cap redirects 28,000 to employee contributions and the broad-base -# haircut reduces pay by 280. Threshold income still adds back all 30,000: -# 202,720 - 28,000 + 30,000 = 204,720. Pension input remains 130,000, -# adjusted income is 202,720 + 130,000 - 28,000 = 304,720, and the charge -# is (130,000 - 37,640) * 45% = 41,562. +# The unchanged broad-base haircut reduces actual pay by 280. The 28,000 +# NICs excess creates neither ordinary pay nor a member payment. Threshold +# income still adds back all 30,000: 174,720 + 30,000 = 204,720. Pension +# input remains 130,000, adjusted income is 174,720 + 130,000 = 304,720, +# and the charge is (130,000 - 37,640) * 45% = 41,562. - name: The 2029 NICs cap does not limit the salary sacrifice threshold income addback period: 2029 input: @@ -254,8 +254,10 @@ pension_contributions_via_salary_sacrifice: 30_000 employer_pension_contributions: 100_000 output: - employment_income: 202_720 - pension_contributions_relief: 28_000 + employment_income: 174_720 + pension_contributions_relief: 0 + salary_sacrifice_pension_ni_excess: 28_000 + ni_class_1_income: 202_720 pension_contributions_for_annual_allowance: 130_000 pension_annual_allowance_threshold_income: 204_720 pension_annual_allowance_adjusted_income: 304_720 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml new file mode 100644 index 0000000000..26fdbe1c6c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml @@ -0,0 +1,78 @@ +# Relevant UK earnings (FA 2004 s.189(2)) set the annual limit on relief for a +# person's own pension contributions (s.190). They are employment income +# chargeable to income tax, which is net taxable earnings (ITEPA 2003 ss.9(2) +# and 11), so allowable expenses (s.327(3)) come off; the pension deductions +# s.327(5) also lists are the relief these earnings limit, so they do not. +# They also include trading income chargeable under ITTOIA +# 2005 Part 2, which is after capital allowances (CAA 2001 s.247) and the +# trading allowance (ITTOIA 2005 s.783AF) but before relief for losses brought +# forward (ITA 2007 s.83, a Step 2 deduction). pension_contributions_relief and +# the pension deduction in adjusted_total_income (ITA 2007 s.24A(8)) use the +# same figure. Each case is a person aged 45 in England with no other income. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/189 +# https://www.legislation.gov.uk/ukpga/2003/1/section/11 +# https://www.legislation.gov.uk/ukpga/2003/1/section/327 +# https://www.legislation.gov.uk/ukpga/2001/2/section/247 +# https://www.legislation.gov.uk/ukpga/2005/5/section/783AF + +- name: Capital allowances reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + capital_allowances: 4_000 + personal_pension_contributions: 9_000 + output: + # Part 2 profit is 10,000 - 4,000 = 6,000, above the 3,600 basic amount, + # so relief stops at 6,000 of the 9,000 paid. + pension_contributions_relief: 6_000 + # Total income 6,000 less the same 6,000 of relief. + adjusted_total_income: 0 + +- name: Allowable employment expenses reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 20_000 + employment_expenses: 5_000 + employee_pension_contributions_reported: 18_000 + output: + # Earnings after the 5,000 of expenses are 15,000, so a net pay + # arrangement relieves 15,000 of the 18,000 paid. + taxable_employment_income: 15_000 + pension_contributions_relief: 15_000 + adjusted_total_income: 0 + +- name: Losses brought forward do not reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + loss_relief: 6_000 + personal_pension_contributions: 8_000 + output: + # The loss reduces taxable trading income to 4,000 but not the Part 2 + # profit of 10,000, so all 8,000 is relieved. + taxable_self_employment_income: 4_000 + pension_contributions_relief: 8_000 + # Total income adds the 6,000 of brought-forward loss back: 10,000 less + # 8,000 of relief. + adjusted_total_income: 2_000 + +- name: A profit within the trading allowance is not relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 1_000 + self_employment_income: 800 + employee_pension_contributions_reported: 1_500 + output: + # The trading allowance fully relieves the 800 profit, so relevant UK + # earnings are the 1,000 of pay. Net pay has no basic-amount increase + # (s.191(7)), so relief stops at 1,000. + taxable_self_employment_income: 0 + pension_contributions_relief: 1_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml index 2f3573564d..8ac4e451ce 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml @@ -1,6 +1,4 @@ -# From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -# employment income. Only people with pay have a salary sacrifice, so the cap -# never gives pay to a person who has none. +# The cap creates NIC earnings only; ordinary pay/member payments stay unchanged. - name: No pay and a salary sacrifice input gives no employment income, 2029 period: 2029 @@ -34,7 +32,7 @@ salary_sacrifice_returned_to_income: 0 employment_income: 0 -- name: Pay above the sacrifice returns the excess over the cap, 2029 +- name: Pay above the sacrifice has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -43,11 +41,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 5,000 - 2,000 cap - salary_sacrifice_returned_to_income: 3_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 3_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 43_000 + employment_income: 40_000 -- name: A whole-bonus sacrifice above the remaining salary returns its full excess, 2030 +- name: A whole-bonus sacrifice above the remaining salary has its full NIC excess, 2030 period: 2030 absolute_error_margin: 0.01 input: @@ -56,11 +55,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 50,000 - 2,000 cap - salary_sacrifice_returned_to_income: 48_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 48_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 88_000 + employment_income: 40_000 -- name: Low pay with a large sacrifice still returns the excess over the cap, 2029 +- name: Low pay with a large sacrifice still has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -68,9 +68,10 @@ pension_contributions_via_salary_sacrifice: 30_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_returned_to_income: 28_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 28_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 29_500 + employment_income: 1_500 - name: Nothing returns before the cap starts, 2028 period: 2028 @@ -82,3 +83,63 @@ salary_sacrifice_returned_to_income: 0 pension_contributions_via_salary_sacrifice_adjusted: 5_000 employment_income: 40_000 + +# Issue #2173: cash pay is already after the contractual employer sacrifice. +# The unchanged 0.16% broad-base haircut reduces 97,000 by 155.20; the 8,000 +# NIC-only excess does not enter ANI or become a member pension payment. +- name: NI-only sacrifice excess preserves both childcare income tests, 2173 + period: 2029 + absolute_error_margin: 0.02 + input: + people: + parent: + age: 40 + employment_income_before_lsr: 97_000 + pension_contributions_via_salary_sacrifice: 10_000 + child: + age: 2 + benunits: + family: + members: [parent, child] + households: + household: + members: [parent, child] + region: LONDON + output: + employment_income: [96_844.80, 0] + adjusted_net_income: [96_844.80, 0] + ni_class_1_income: [104_844.80, 0] + salary_sacrifice_pension_ni_excess: [8_000, 0] + salary_sacrifice_returned_to_income: [0, 0] + employee_pension_contributions: [0, 0] + tax_free_childcare_meets_income_requirements: [true, false] + extended_childcare_entitlement_meets_income_requirements: [true, false] + +# Conditional supplied-ANI contract, paired with the parent's 2026 SSP case. +# The cap is inactive, so there is no broad-base haircut. The supplied return +# is actual pay/member cash: 19,500 + 500 pay and 21,500 + 500 member payments. +- name: Actual returned pay and SSP retain supplied-ANI annual allowance treatment + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income_before_lsr: 19_500 + salary_sacrifice_returned_to_income: 500 + statutory_sick_pay: 2_000 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 21_500 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5_000 + output: + employment_income: 20_000 + employment_benefits: 2_000 + employee_pension_contributions: 22_000 + pension_contributions_relief: 22_000 + net_income: 27_000 + pension_contributions_for_annual_allowance: 22_000 + pension_annual_allowance_threshold_income: 5_000 + pension_annual_allowance_adjusted_income: 27_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml new file mode 100644 index 0000000000..bc5ff5b182 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml @@ -0,0 +1,122 @@ +# Unused annual allowance carried forward (FA 2004 s. 228A) where no history +# is supplied. From 2020-21 the default assumes the person's pension input in +# each of the five previous years was this year's (prior_year_input_ratio 1) +# deflated by average earnings growth, the incomes tested for the taper +# likewise, and that they were a scheme member in each year. Each year's +# excess uses the unused allowance of the three years before it, earliest +# first (s. 228A(7)), and the unused allowance of the three years before this +# one is carried forward. +# +# For 2026 the deflators are average earnings growth of 3.4% (2026), 5.3% +# (2025), 5.1% (2024), 6.22% (2023) and 6.14% (2022): 0.967118 for 2025, +# 0.918441 for 2024, 0.873873 for 2023, 0.822701 for 2022 and 0.775109 for +# 2021. The standard allowance is 40,000 to 2022-23 and 60,000 from 2023-24. +# Every case is a person aged 45 in London with employment income of 100,000. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/228A +# https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + +- name: Steady saving under the allowance carries forward three years of unused allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 50_000 + output: + # Inputs of 43,693.66 (2023), 45,922.03 (2024) and 48,355.90 (2025) + # leave 16,306.34 + 14,077.97 + 11,644.10 of their 60,000 allowances. + unused_pension_annual_allowance: 42_028.41 + pension_annual_allowance_chargeable_amount: 0 + +- name: An excess in a later year uses the earliest unused allowance first + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 63_000 + output: + # 2023 input 55,054.01 leaves 4,945.99 unused and 2024 input 57,861.76 + # leaves 2,138.24. 2025 input 60,928.43 exceeds 60,000 by 928.43, which + # uses 2023's unused allowance (2021 and 2022 have none). 4,017.56 + + # 2,138.24 = 6,155.80, against 7,084.23 without that ordering. + unused_pension_annual_allowance: 6_155.80 + # 63,000 - 60,000 is within the 6,155.80 carried forward. + pension_annual_allowance_chargeable_amount: 0 + +- name: Saving above the allowance every year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + output: + # Deflated inputs of 54,258 to 67,698 exceed each year's allowance. + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 10_000 + +- name: A supplied unused allowance overrides the default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + unused_pension_annual_allowance: 4_000 + output: + pension_annual_allowance_chargeable_amount: 6_000 + +- name: No pension input this year means no carry-forward is assumed + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + output: + unused_pension_annual_allowance: 0 + +- name: A tapered allowance in every earlier year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 400_000 + employer_pension_contributions: 50_000 + output: + # Adjusted income of 450,000 tapers the allowance to the 10,000 minimum + # (60,000 - (450,000 - 260,000) / 2 is below it). Deflated, the earlier + # years' incomes are tapered to their minimums too (4,000 in 2021-22 and + # 2022-23), all below the deflated inputs. + pension_annual_allowance: 10_000 + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 40_000 + +- name: Before 2020-21 the default is nil + period: 2019 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 30_000 + output: + unused_pension_annual_allowance: 0 + +- name: A taper that binds only in an earlier year cuts what that year leaves + period: 2022 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 180_000 + employer_pension_contributions: 20_000 + output: + # Adjusted income of 200,000 is under 2022-23's taper limit of 240,000. + # Deflated by earnings growth of 6.14% (2022), 5.9% (2021) and 0.3% + # (2020), the 2019-20 adjusted income is 177,400.16 and threshold income + # 159,660.15, over that year's limits of 150,000 and 110,000, so its + # allowance is 40,000 - floor((177,400.16 - 150,000) / 2) = 26,300, and + # its input of 17,740.02 leaves 8,559.98. 2020-21 and 2021-22 are under + # 240,000 and leave 22,206.76 and 21,156.96 of 40,000. Untapered, 2019-20 + # would leave 22,259.98, for 65,623.70 in all. + pension_annual_allowance: 40_000 + unused_pension_annual_allowance: 51_923.71 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml index 0fb76a6d56..68b1057fb6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax.yaml @@ -20,13 +20,13 @@ main_residential_property_purchased_is_first_home: true output: stamp_duty_land_tax: 7_250 -- name: £500k first-time buyer (just over limit) +- name: £500,020 first-time buyer (over the £500k limit) period: 2022 input: - main_residential_property_purchased: 500_000.01 + main_residential_property_purchased: 500_020 main_residential_property_purchased_is_first_home: true output: - stamp_duty_land_tax: 15_000 + stamp_duty_land_tax: 15_001 - name: £500k non-first-time buyer period: 2022 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml new file mode 100644 index 0000000000..5344590020 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_history.yaml @@ -0,0 +1,150 @@ +# Ordinary, unprotected purchases; annual parameters sample 30 April. +# Sources: SDLT (Temporary Relief) Act 2023 s1; Finance Act 2025 ss50-51; +# Finance Act 2003 s55 and Sch6ZA. No transaction-date or contract selector. +- name: Annual 2022 retains the pre-September standard schedule + period: 2022 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300000 + output: + stamp_duty_land_tax: 5000 + +- name: Annual 2023 main-home purchase uses temporary standard bands + period: 2023 + absolute_error_margin: 0.01 + input: + main_residence_value: 300000 + property_purchased: true + output: + stamp_duty_land_tax: 2500 + main_residential_property_purchased: 300000 + +- name: Temporary first-time-buyer maximum is inclusive + period: 2024 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 625000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 10000 + +- name: Restored first-time-buyer maximum is inclusive + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 500000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 10000 + +- name: Annual 2025 additional purchase uses restored bands with five-point surcharge + period: 2025 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 30000 + +- name: Annual 2024 still samples the three-point additional surcharge + period: 2024 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 19500 + +- name: First-time purchase above temporary maximum loses all relief + period: 2024 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 625020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 18751 + +- name: First-time purchase above restored maximum loses all relief + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 500020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 15001 + +- name: Northern Ireland combines independent temporary main and additional purchases + period: 2024 + absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + main_residential_property_purchased: 925000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 53250 + +- name: An additional purchase below forty thousand is outside the higher schedule + period: 2025 + input: + additional_residential_property_purchased: 39999 + output: + stamp_duty_land_tax: 0 + +- name: An additional purchase at forty thousand pays the full higher schedule + period: 2025 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 40000 + output: + stamp_duty_land_tax: 2000 + +- name: Restored purchase schedules persist in later annual simulations + period: 2027 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 35000 + +- name: Property stocks without a purchase do not incur SDLT + period: 2025 + input: + main_residence_value: 600000 + property_purchased: false + other_residential_property_value: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Scottish purchases do not incur SDLT + period: 2025 + input: + region: SCOTLAND + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Welsh purchases do not incur SDLT + period: 2025 + input: + region: WALES + main_residential_property_purchased: 300000 + additional_residential_property_purchased: 400000 + output: + stamp_duty_land_tax: 0 + +- name: Temporary first-time-buyer nil band includes its upper boundary + period: 2024 + input: + main_residential_property_purchased: 425000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 0 + +- name: Restored first-time-buyer tax starts above three hundred thousand + period: 2025 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300020 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 1 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml new file mode 100644 index 0000000000..f736d70c19 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax/simulated_council_tax.yaml @@ -0,0 +1,438 @@ +- name: England Band A is 2/3 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: A + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 1668.39 + +- name: England Band B is 7/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: B + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 1946.46 + +- name: England Band C is 8/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: C + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2224.52 + +- name: England Band D is 1/1 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 + +- name: England Band E is 11/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: E + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 3058.72 + +- name: England Band F is 13/9 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: F + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 3614.85 + +- name: England Band G is 5/3 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: G + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 4170.98 + +- name: England Band H is 2/1 of the Maidstone Band D amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: H + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 5005.18 + +- name: Wales Band I (the ninth, Wales-only band) is 21/9 of Band D + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: I + output: + council_tax_band_d_amount: 2013.18 + simulated_council_tax: 4697.42 + +- name: "Band D identity: liability equals the Band D amount in Cardiff" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: D + output: + council_tax_band_d_amount: 2013.18 + simulated_council_tax: 2013.18 + +- name: Scotland Band H uses the 882/360 multiplier from April 2017 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1626.05 + simulated_council_tax: 3983.82 + +- name: "Band D identity: liability equals the Band D amount in Maidstone" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 + +- name: "Fiscal 2025 carries its own Band D level in England (not the 2026-27 one)" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax_band: D + output: + council_tax_band_d_amount: 2403.78 + simulated_council_tax: 2403.78 + +- name: "Fiscal 2025 carries its own Band D level in Wales" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: WALES + local_authority: CARDIFF + council_tax_band: D + output: + council_tax_band_d_amount: 1926.18 + simulated_council_tax: 1926.18 + +- name: "Scotland Band H at 2025 uses the 2025-26 Band D level" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1563.51 + simulated_council_tax: 3830.60 + +- name: "Scotland Band H before April 2017 used the original 18/9 proportion" + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax_band: H + output: + council_tax_band_d_amount: 1563.51 + simulated_council_tax: 3127.02 + +- name: "A 2023 unitary authority resolves to its own Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_WEST + local_authority: SOMERSET + council_tax_band: D + output: + council_tax_band_d_amount: 2560.59 + simulated_council_tax: 2560.59 + +- name: "Westmorland and Furness, a 2023 unitary, loads at 2025" + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTH_WEST + local_authority: WESTMORLAND_AND_FURNESS + council_tax_band: D + output: + council_tax_band_d_amount: 2389.00 + simulated_council_tax: 2389.00 + +- name: "North Yorkshire, a 2023 unitary, resolves to its own Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: YORKSHIRE + local_authority: NORTH_YORKSHIRE + council_tax_band: D + output: + council_tax_band_d_amount: 2544.34 + simulated_council_tax: 2544.34 + +- name: "Northern Ireland levies domestic rates, so there is no Band D amount or council tax" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTHERN_IRELAND + local_authority: BELFAST + council_tax_band: D + output: + council_tax_band_d_amount: 0 + simulated_council_tax: 0 + +- name: "A district abolished in April 2023 (Allerdale) has no Band D amount" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: NORTH_WEST + local_authority: ALLERDALE + council_tax_band: D + output: + council_tax_band_d_amount: 0 + simulated_council_tax: 0 + +- name: "With no local authority supplied, the household takes the enum default, Maidstone" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + region: SOUTH_EAST + council_tax_band: D + output: + council_tax_band_d_amount: 2502.59 + simulated_council_tax: 2502.59 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index 405fc1ffc3..d4f30bd840 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -140,10 +140,8 @@ simulated_council_tax_reduction_benunit: [759.60] - name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (SI 2012/2885 reg 9(1); + # they are not in her family), and neither is in education. period: 2026 absolute_error_margin: 0.01 input: @@ -156,10 +154,12 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION baby: age: 0 is_household_head: false @@ -183,7 +183,12 @@ council_tax_reduction_relevant_income_based_benefit: [false] # Neither the couple's JSA nor their Child Benefit is her income. council_tax_reduction_applicable_income: [18_514] - simulated_council_tax_reduction_benunit: [759.60] + # 759.60 before non-dependant deductions. The JSA is payable to one of + # the couple, who is exempt (Sch 1 para 8(8)); the other, not in + # remunerative work, pays the lowest amount, 5.20 a week, the couple's + # one deduction (para 8(3)): 270.40. + council_tax_reduction_non_dep_deductions: [270.40] + simulated_council_tax_reduction_benunit: [489.20] - name: A head who applies alone is a single person, with premiums on her own disability only period: 2026 @@ -323,10 +328,8 @@ merton_council_tax_reduction: [1_800, 0] - name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (para 9(1); they are not + # in her family), and neither is in education. period: 2026 absolute_error_margin: 0.01 input: @@ -338,10 +341,13 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION + weekly_hours: 37.5 employment_income: 30_000 baby: age: 0 @@ -365,9 +371,13 @@ council_tax_reduction_applicable_income: [0] # Single aged 25 or over: 95.55 * 52. council_tax_reduction_applicable_amount: [4_968.60] - # No excess income: 1,800 * 70%. On the Universal Credit route the - # couple's earnings and award would leave nothing. - newham_council_tax_reduction: [1_260] + # No excess income: 1,800 * 70% = 1,260. On the Universal Credit route + # the couple's earnings and award would leave nothing. The couple pay one + # deduction (para 30B(3)): the father's £22.61 a week on their joint + # £576.92, 1,175.72 a year; the mother, on Universal Credit with no + # earned income of her own, is exempt. + newham_council_tax_reduction_non_dep_deductions: [1_175.72] + newham_council_tax_reduction: [84.28] - name: A partner under 18 is not liable, but their income counts in the couple's means test period: 2026 @@ -495,10 +505,10 @@ council_tax_reduction_applicable_amount: [7_807.80] - name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames - # Provisional until in-unit non-dependants land (#2017): the 19-year-old - # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in - # her family), but the model charges a deduction only for non-dependants - # in another benefit unit, so this case has none. + # The 19-year-old parents are her non-dependants (para 9(1); they are not + # in her family), and neither is in education. Both are on the couple's + # Universal Credit with no earned income, so both are exempt under para + # 30A's Universal Credit exemption, and there is no deduction. period: 2026 absolute_error_margin: 0.01 input: @@ -511,10 +521,12 @@ age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION young_father: age: 19 is_parent: true is_household_head: false + current_education: NOT_IN_EDUCATION baby: age: 0 is_household_head: false @@ -537,6 +549,7 @@ council_tax_reduction_applicable_amount: [4_968.60] # Tariff income ceil((10,000 - 6,000) / 250) = £16 a week, 832 a year, # since she has no award. 1,800 - (5,000 + 832 - 4,968.60) * 20%. + kingston_upon_thames_council_tax_reduction_non_dep_deductions: [0] kingston_upon_thames_council_tax_reduction: [1_627.32] - name: A blind grandmother head's exemption does not exempt a sharer's claim from a non-dependant deduction in Merton diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml new file mode 100644 index 0000000000..ee29ceeca5 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -0,0 +1,610 @@ +# Industrial injuries disablement benefit (IIDB), severe disablement allowance +# (SDA), incapacity benefit (IB) and war pensions in Council Tax Reduction +# income. +# +# Every scheme counts IIDB, SDA and IB: +# - England, pensioners: SI 2012/2885 Sch 1 para 16(1)(j), social security +# benefits other than those listed; (j)(v) excepts only IIDB's increases +# under SSCBA 1992 ss.104 and 105. +# - English working-age schemes: gross income (SI 2012/2886 Sch para 54(1)) +# less Sch 8, which disregards none of the three (para 14 disregards +# "attendance allowance", defined to include the ss.104 and 105 increases). +# Kingston upon Thames, Merton and Newham copy the default scheme; Oxford's +# para 24 counts income other than earnings unless its Appendix 5 lists it. +# - Wales: WSI 2013/3029 Sch 1 para 10(1)(j) and Sch 6 para 17(1) with Sch 9. +# - Scotland: SSI 2012/319 reg 27(1)(j) (pension age) and SSI 2021/249 reg +# 57(1)(b)(ix) to (xi) (working age, a closed list). +# +# War pensions and AFCS payments (afcs, war_widows_pension): +# - Scotland counts none of them: SSI 2012/319 reg 31(10) and Sch 3 para 1 +# disregard each payment in full, and SSI 2021/249 reg 57(1) does not name +# them. +# - England and Wales prescribe a £10 a week disregard (SI 2012/2885 Sch 5 +# para 1; WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20) that councils may +# exceed (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and +# s.13A(1)(c)). The model takes the full disregard where it cannot see the +# council's choice. +# - Of the councils whose schemes the model reads, Westminster disregards war +# pensions in full and Oxford ignores them for applicants without Universal +# Credit. Newham's scheme keeps the £10, but its discretionary reduction +# policy (para 2.1) automatically adds what a full disregard would give. +# Kingston upon Thames's schedules keep the £10 while its para 36(6) speaks +# of a full disregard, and Merton's "in full" disregard is "subject to +# paragraph 40" (a £20 a week limit). The model approximates all of them +# with the full disregard. +# - Only the applicant's and partner's IIDB, SDA and IB count (SI 2012/2885 +# Sch 1 para 11; SSI 2021/249 reg 36(1)), not a dependant's. +# +# Weekly rates, 2026-27 (SI 2026/148 Sch 1, restating SSCBA 1992 Sch 4): +# disablement pension at 100% 233.90, 50% 116.95, 20% 46.78; long-term +# incapacity benefit 146.60; severe disablement allowance 103.85 plus the +# higher age related addition 15.50, 119.35 in all. Full new State Pension +# 241.30 (SI 2026/148 art 6(1)), 12,547.60 a year, below the 12,570 personal +# allowance. IIDB, SDA and war pensions are not taxed in the model; State +# Pension and IB are. Pension Credit standard minimum guarantee 238.00 +# (single). CTR applicable amounts: personal allowance 256.00 for a single +# person who reached pensionable age before 1 April 2021 (SI 2012/2885 Sch 2 +# para 1, as uprated by SI 2026/27 reg 6; SSI 2026/56 reg 6 in Scotland), so +# the pensioners with an award asserted below are 80; and 95.55 for a single +# person aged 25 or over in Scotland's working-age scheme (SSI 2026/56 reg +# 14). The reduction is withdrawn at 20% of income above the applicable +# amount. Council tax is 2,000 throughout. + +- name: IIDB counts as pension-age Council Tax Reduction income in England + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 80 + state_pension: 241.30 * 52 + iidb_reported: 116.95 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: true + income_tax: 0 + # 12,547.60 + 6,081.40 + council_tax_reduction_applicable_income: 241.30 * 52 + 116.95 * 52 + council_tax_reduction_applicable_amount: 256.00 * 52 + # 2,000 - 0.2 x (18,629.00 - 13,312.00) = 936.60 + council_tax_benefit: 2_000 - 0.2 * ((241.30 + 116.95 - 256.00) * 52) + +- name: IIDB counts as pension-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 241.30 * 52 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + # 12,547.60 + 12,162.80 + council_tax_reduction_applicable_income: 241.30 * 52 + 233.90 * 52 + +- name: IIDB counts as pension-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 80 + state_pension: 241.30 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + # 12,547.60 + 2,432.56 + council_tax_reduction_applicable_income: 241.30 * 52 + 46.78 * 52 + council_tax_reduction_applicable_amount: 256.00 * 52 + # 2,000 - 0.2 x (14,980.16 - 13,312.00) = 1,666.37 + council_tax_benefit: 2_000 - 0.2 * ((241.30 + 46.78 - 256.00) * 52) + +- name: SDA counts as working-age Council Tax Reduction income under the Westminster scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + sda_reported: 1 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: false + # 103.85 + 15.50 + sda: 119.35 * 52 + council_tax_reduction_applicable_income: 119.35 * 52 + +- name: IB counts as working-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + # 7,623.20 is below the personal allowance. + income_tax: 0 + council_tax_reduction_applicable_income: 146.60 * 52 + +- name: IIDB and IB count as working-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 50 + iidb_reported: 46.78 * 52 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: false + income_tax: 0 + # 2,432.56 + 7,623.20 + council_tax_reduction_applicable_income: (46.78 + 146.60) * 52 + council_tax_reduction_applicable_amount: 95.55 * 52 + # 2,000 - 0.2 x (10,055.76 - 4,968.60) = 982.57 + council_tax_benefit: 2_000 - 0.2 * ((46.78 + 146.60 - 95.55) * 52) + +- name: Taxable IB counts net of income tax under the Westminster scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 55 + incapacity_benefit_reported: 146.60 * 52 + private_pension_income: 10_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + # 0.2 x (7,623.20 + 10,000 - 12,570) = 1,010.64 + income_tax: 0.2 * (146.60 * 52 + 10_000 - 12_570) + # 17,623.20 - 1,010.64 = 16,612.56 + council_tax_reduction_applicable_income: 146.60 * 52 + 10_000 - 0.2 * (146.60 * 52 + 10_000 - 12_570) + +- name: War pensions do not count as pension-age Council Tax Reduction income in England + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + state_pension: 241.30 * 52 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + income_tax: 0 + council_tax_reduction_applicable_income: 241.30 * 52 + +- name: War pensions do not count as working-age Council Tax Reduction income in Wales + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + afcs_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: WALES + region: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: War pensions do not count as pension-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 80 + state_pension: 241.30 * 52 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 241.30 * 52 + +- name: War pensions do not count as working-age Council Tax Reduction income in Scotland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + afcs_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: SCOTLAND + region: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: The Westminster working-age scheme disregards war pensions in full + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: War pensions do not count under the Kingston upon Thames working-age scheme + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + afcs_reported: 5_000 + war_widows_pension_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_applicable_income: 0 + +- name: War pensions do not count for a pensioner in Newham + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + afcs_reported: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: LONDON + local_authority: NEWHAM + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 0 + +- name: A dependant's IIDB does not count as Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 80 + state_pension: 241.30 * 52 + dependant: + age: 17 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [pensioner, dependant] + would_claim_pc: false + households: + household: + members: [pensioner, dependant] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # Only the applicant's and partner's income counts (Sch 1 para 11); + # pensioner schemes also exclude Child Benefit. + council_tax_reduction_applicable_income: 241.30 * 52 + +- name: Where a household head applies alone, another couple's IIDB in her benefit unit does not count + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + mother: + age: 45 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + father: + age: 46 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + # The grandmother is the applicant and has no partner here (SI 2012/2885 + # Sch 1 para 11); the parents are not her partners. + council_tax_reduction_head_applies_alone: true + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_applicable_income: 0 + +- name: Where a household head applies alone, her own IIDB counts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + iidb_reported: 116.95 * 52 + mother: + age: 45 + is_parent: true + is_household_head: false + father: + age: 46 + is_parent: true + is_household_head: false + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_applicable_income: 116.95 * 52 + +- name: Where a household head applies alone, her own IIDB counts with her State Pension, and the father's does not + # Only the merged code counts both: the head's State Pension through the + # applicant's member mask, her IIDB through the applicant-and-partner mask. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + state_pension: 241.30 * 52 + iidb_reported: 46.78 * 52 + mother: + age: 45 + is_parent: true + is_household_head: false + father: + age: 46 + is_parent: true + is_household_head: false + iidb_reported: 233.90 * 52 + child: + age: 10 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, child] + would_claim_uc: false + would_claim_pc: false + would_claim_child_benefit: false + households: + home: + members: [grandmother, mother, father, child] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_pensioner: true + # State Pension 12,547.60, below the 12,570 personal allowance. + income_tax: [0, 0, 0, 0] + # State Pension 241.30 * 52 = 12,547.60, plus her IIDB at 20% + # 46.78 * 52 = 2,432.56: 14,980.16. The father's 12,162.80 IIDB is not + # hers (SI 2012/2885 Sch 1 para 11). + council_tax_reduction_applicable_income: 14_980.16 + # A single pensioner who reached pensionable age before April 2021: + # 256.00 * 52 = 13,312.00. + council_tax_reduction_applicable_amount: 13_312 + # 1,800 - 0.2 x (14,980.16 - 13,312.00) = 1,800 - 333.632 = 1,466.368, + # less a non-dependant deduction: the couple are in her benefit unit but + # not her family, so are her non-dependants (SI 2012/2885 reg 9; #2017). + # They pay one deduction, the higher (Sch 1 para 8(3)); neither is in + # remunerative work, so each would pay the lowest amount, 5.20 a week + # (para 8(1)(b)): 5.20 x 52 = 270.40. 1,466.368 - 270.40 = 1,195.968. + council_tax_reduction_non_dep_deductions: 270.40 + council_tax_benefit: 1_195.968 + +- name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 150 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [pensioner] + would_claim_pc: true + households: + household: + members: [pensioner] + country: ENGLAND + region: SOUTH_EAST + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # 7,800 + 2,432.56 = 10,232.56, or 196.78 a week, below the 238.00 + # minimum guarantee (SPC Regs 2002 reg 15(1) counts IIDB). + pension_credit_income: (150 + 46.78) * 52 + in_receipt_of_guarantee_credit: true + # SI 2012/2885 Sch 1 para 13: the whole income is disregarded. + council_tax_reduction_applicable_income: 0 + council_tax_benefit: 2_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml new file mode 100644 index 0000000000..4b6b546772 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/local_scheme_non_dependants.yaml @@ -0,0 +1,945 @@ +# Non-dependant deductions in the English working-age local schemes. +# +# Each council's scheme reproduces the Default Scheme's definition: a +# non-dependant is anyone who normally resides with the applicant other than +# a member of the applicant's family (a partner, or a child or qualifying young +# person the applicant is responsible for), a joint occupier, a commercial +# lodger or a paid carer (para 9; Oxford paras 42 and Annex 1). So an adult in +# the applicant's own benefit unit who is not the claimant, partner, child or +# young person is a non-dependant, and so is each adult in another family. +# Each non-dependant aged 18 or over is banded on their own normal gross weekly +# income and is in remunerative work on their own hours (para 30(1)-(2) and +# para 10); a couple's income is joint and the couple pays one deduction, the +# higher (para 30(3)-(4); Oxford para 44), except that Merton, Kingston upon +# Thames and Westminster deduct for each member of a couple with a Universal +# Credit award (the Default Scheme's para 30(3), on which Westminster's +# working-age scheme is based). The benefit-receipt exemptions (para 30(8)) apply to the person on +# the award: Income Support, income-based JSA, income-related ESA and State +# Pension Credit to the member they are payable to, and Universal Credit to +# each joint claimant who has no earned income of their own. +# +# Weekly amounts for 2026-27 (52 weeks a year): +# - Merton (para 30) and Westminster (Appendix A), Oxford (para 43): £5.20 not +# in remunerative work or under £279; £10.60 from £279; £13.30 from £485; +# £15.95 from £605. +# - Kingston upon Thames (para 30A): £5.64; £11.50 from £279; £14.43 from +# £485; £17.31 from £605. +# - Newham (para 30B): £7.54; £14.96 from £183; £18.84 from £316; £22.61 from +# £394. +# Earnings of £30,000 are £576.92 a week, £20,000 are £384.62 and £12,000 are +# £230.77. The claimant has no income and no capital, so Merton, Kingston +# upon Thames, Westminster and Oxford support 100% of the £1,800 council tax +# and Newham 70% (£1,260), less the deductions. +# +# The claimant and partner flags are set because the model's inference treats +# an adult child as a lone parent's partner. + +- name: Merton charges the applicant's own non-dependant son for his own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + # £13.30 a week on £576.92. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60] + merton_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Merton charges a 19-year-old who has left education in the applicant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + daughter: + # Left education, so not a qualifying young person or a student (the + # education input otherwise defaults to tertiary at 19). + age: 19 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + employment_income: 12_000 + weekly_hours: 30 + benunits: + benunit: + members: [parent, daughter] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, daughter] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + # In remunerative work on £230.77 a week, below £279: £5.20. + merton_council_tax_reduction_non_dep_deductions: 270.40 + council_tax_reduction: 1_529.60 + +- name: Merton deducts separately for a lone parent and her working son in another family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # The mother is not in remunerative work (£5.20) and is not banded on + # her son's earnings; the son pays £13.30 on his own. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] + merton_council_tax_reduction_non_dep_deductions: [962, 0] + council_tax_reduction: 838 + +- name: Merton exempts a non-dependant on Income Support but not the adult son in her benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + income_support: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(8)(a): the Income Support is the mother's, not her son's. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + council_tax_reduction: 1_108.40 + +- name: Merton exempts a Universal Credit non-dependant without earnings but not her earning son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + universal_credit: 1_000 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(8)(c): the mother's award is calculated on no earned income, as + # her son's earnings are not hers; he is not on her award. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: Merton deducts for each member of a Universal Credit couple on their own hours and earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(3): no single deduction where there is a UC award. The worker + # pays £13.30 on the couple's joint £576.92. Para 30(8)(c) exempts one + # entitled to UC "calculated on the basis that the person does not have + # any earned income", read on each person's own earnings as in the + # national schemes: the partner has none, so pays nothing; the worker is + # not exempt. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 0] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 + +- name: Merton makes one deduction for a couple not on Universal Credit, on joint income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + first: + age: 35 + is_claimant_or_partner: true + employment_income: 15_000 + weekly_hours: 37.5 + second: + age: 35 + is_claimant_or_partner: true + employment_income: 15_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [first, second] + would_claim_uc: false + households: + household: + members: [claimant, first, second] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(4): £288.46 each, £576.92 jointly: £13.30, deducted once. + merton_council_tax_reduction_individual_non_dep_deduction: [0, 691.60, 691.60] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 + +- name: Merton makes no deduction for the son of an applicant who receives PIP daily living + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + pip_dl: 1_000 + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30(6)(b)(iii). + merton_council_tax_reduction_non_dep_deductions: 0 + council_tax_reduction: 1_800 + +- name: Kingston upon Thames charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 0 + output: + # Para 30A: £14.43 a week on £576.92. + kingston_upon_thames_council_tax_reduction_non_dep_deductions: 750.36 + council_tax_reduction: 1_049.64 + +- name: Kingston upon Thames deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 0 + output: + # Mother £5.64; son £11.50 on £384.62. + kingston_upon_thames_council_tax_reduction_individual_non_dep_deduction: [0, 293.28, 598] + council_tax_reduction: 908.72 + +- name: Newham charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Para 30B: £22.61 a week on £576.92; 70% of £1,800 is £1,260. + newham_council_tax_reduction_non_dep_deductions: 1_175.72 + council_tax_reduction: 84.28 + +- name: Newham deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 12_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Mother £7.54; son £14.96 on £230.77. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 392.08, 777.92] + council_tax_reduction: 90 + +- name: Newham makes one deduction for a Universal Credit couple, the working member's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + # Para 30B(3) has no Universal Credit exception: the higher of the two, + # the worker's £22.61. The partner, with no earned income of her own, is + # exempt under para 30B's Universal Credit exemption. + newham_council_tax_reduction_individual_non_dep_deduction: [0, 1_175.72, 0] + newham_council_tax_reduction_non_dep_deductions: [1_175.72, 0] + council_tax_reduction: 84.28 + +- name: Westminster deducts for each member of a Universal Credit couple, as the Default Scheme does + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + worker: + age: 35 + is_claimant_or_partner: true + employment_income: 30_000 + weekly_hours: 37.5 + partner: + age: 35 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 10 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + couple: + members: [worker, partner] + universal_credit: 1_000 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + # Westminster's working-age scheme is based on the Default Scheme, whose + # para 30(3) makes no single deduction where there is an award of + # Universal Credit. The worker pays £15.95 on the couple's joint £626.92 + # a week (Appendix A); the partner, earning but not in remunerative work, + # £5.20, and is not exempt, having earned income of her own. + westminster_council_tax_reduction_individual_non_dep_deduction: [0, 829.40, 270.40] + westminster_council_tax_reduction_non_dep_deductions: [1_099.80, 0] + council_tax_reduction: 700.20 + +- name: Westminster charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + westminster_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Westminster makes no deduction for a full-time student son in the applicant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + in_HE: true + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + westminster_council_tax_reduction_non_dep_deductions: 0 + council_tax_reduction: 1_800 + +- name: Westminster exempts a non-dependant on income-based JSA but not the adult son in her benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + jsa_income: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + output: + westminster_council_tax_reduction_individual_non_dep_deduction: [0, 0, 691.60] + council_tax_reduction: 1_108.40 + +- name: Oxford charges the applicant's own non-dependant son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Para 43: £13.30 a week on £576.92; the claimant's own income band is + # the 100% band. + oxford_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Oxford deducts separately for a lone parent and her working son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Mother £5.20; son £10.60 on £384.62. + oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 551.20] + council_tax_reduction: 978.40 + +- name: Oxford keeps deducting for a working-age non-dependant on Income Support + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + mother: + age: 40 + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + other_family: + members: [mother, son] + would_claim_uc: false + income_support: 1 + households: + household: + members: [claimant, mother, son] + country: ENGLAND + local_authority: OXFORD + council_tax: 1_800 + savings: 0 + output: + # Para 47: the benefit-receipt exemptions are for pensioner applicants + # only. + oxford_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 691.60] + council_tax_reduction: 838 + +# Para 9(2)(d)-(e) exclude the person, whatever family they are in: someone +# outside the household head's family who pays the householder as a boarder +# or lodger, and a joint tenant, are nobody's non-dependants. Para 9(3)(a)(i) +# keeps as a non-dependant a close relative who pays; with no relationship +# input, a member of the head's own family who pays board is taken to be one. + +- name: Merton makes no deduction for an adult son who himself pays as a lodger + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 40 + is_claimant_or_partner: true + rent_paid_as_lodger: 3_000 + son: + age: 25 + is_claimant_or_partner: false + rent_paid_as_lodger: 3_000 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + applicant_family: + members: [applicant] + would_claim_uc: false + claims_all_entitled_benefits: true + lodging_family: + members: [lodger, son] + would_claim_uc: false + households: + household: + members: [applicant, lodger, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + council_tax_reduction: 1_800 + +- name: Merton charges a lodger's adult son who does not pay the householder himself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 40 + is_claimant_or_partner: true + rent_paid_as_lodger: 3_000 + son: + age: 25 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + applicant_family: + members: [applicant] + would_claim_uc: false + claims_all_entitled_benefits: true + lodging_family: + members: [lodger, son] + would_claim_uc: false + households: + household: + members: [applicant, lodger, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 9(2)(e) excludes only the person liable to pay the applicant. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + merton_council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_108.40 + +- name: Merton charges the applicant's own son who pays her board, as a close relative + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + rent_paid_as_boarder: 2_600 + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 30: £13.30 a week on £576.92 (para 9(3)(a)(i) keeps him a + # non-dependant although he pays). + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + merton_council_tax_reduction_non_dep_deductions: 691.60 + council_tax_reduction: 1_108.40 + +- name: Merton makes no deduction for an adult son who is himself a joint tenant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + is_liable_for_household_rent: true + employment_income: 30_000 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, son] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Para 9(2)(d): jointly and severally liable with the applicant. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + merton_council_tax_reduction_non_dep_deductions: 0 + +- name: Merton never charges the applicant as their own non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_household_head: true + young_mother: + age: 17 + is_parent: true + young_father: + age: 17 + is_parent: true + baby: + age: 0 + benunits: + benunit: + members: [applicant, young_mother, young_father, baby] + would_claim_uc: false + households: + household: + members: [applicant, young_mother, young_father, baby] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # The inference makes the young parents the benefit unit's couple, so + # the household head is not its claimant or partner; he is still the + # person liable for the council tax, and everyone else is under 18. + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, false] + merton_council_tax_reduction_non_dep_deductions: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml new file mode 100644 index 0000000000..3e9ab4f08f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml @@ -0,0 +1,443 @@ +# CSP earnings condition: SSI2023/302reg14 andSch2para4. +# Constant-through-year actual pay; no earnings/eligibility/award overrides. +- name: Scottish carer with forty thousand pounds of pay gets no CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 40000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + carer_support_payment_pre_overlap: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 + is_carer_for_benefits: + - true + - false +- name: Exactly two hundred and four pounds a week passes the earnings condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 + carers_allowance_earnings: + - 10608 + - 0 + scottish_carer_supplement: + - 608.4 + - 0 +- name: A half penny weekly residual is disregarded in the carers favour + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608.26 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 +- name: A whole penny over the weekly limit excludes CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10608.52 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Half of the individuals pension contribution brings earnings below the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 11000 + care_hours: 40 + employee_pension_contributions: 1000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4495.4 + - 0 + carers_allowance_earnings: + - 10500 + - 0 +- name: The same pay without the pension deduction exceeds the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 11000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Reported Carers Allowance does not bypass the CSP earnings bar + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 40000 + care_hours: 0 + carers_allowance_reported: 4000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + is_carer_for_benefits: + - true + - false +- name: A carer who would not claim receives no CSP even below the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 8000 + care_hours: 40 + would_claim_carers_allowance: false + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: The two hundred and four pound limit is not applied to fiscal year twenty twenty five + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10192.52 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: Exactly the prior years one hundred and ninety six pound limit passes + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 10192 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 4331.6 + - 0 +- name: England keeps Carers Allowance instead of CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 5000 + care_hours: 40 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: ENGLAND + output: + carer_support_payment: + - 0 + - 0 + carers_allowance: + - 4495.4 + - 0 +- name: Below limit earnings do not replace the existing care hours condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + employment_income: 5000 + care_hours: 10 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 +- name: The earnings bar removes underlying CSP before any pension overlap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + employment_income: 40000 + care_hours: 40 + state_pension: 2000 + caree: + age: 50 + pip_dl_category: STANDARD + benunits: + family: + members: + - carer + - caree + households: + home: + members: + - carer + - caree + country: SCOTLAND + output: + carer_support_payment: + - 0 + - 0 + carer_support_payment_pre_overlap: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 + is_entitled_to_carer_benefit: + - false + - false + +- name: Post2444 pension-age Class4 exemption removes CSP when natural net earnings exceed cap + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + self_employment_income: 15000 + personal_pension_contributions: 7700 + care_hours: 40 + country: SCOTLAND + output: + carers_allowance_earnings: 10664 + carer_support_payment_pre_overlap: 0 + +- name: Post2444 SPA-reaching year keeps Class4 deduction and CSP + period: 2026 + absolute_error_margin: 0.01 + input: + age: 66 + self_employment_income: 15000 + personal_pension_contributions: 7700 + care_hours: 40 + country: SCOTLAND + months_since_last_birthday: 4 + output: + carers_allowance_earnings: 10518.2 + carer_support_payment_pre_overlap: 4495.4 + +- name: Post2444 pension-age historical Class2 earnings have no age-exempt deduction + period: 2018 + absolute_error_margin: 0.01 + input: + age: 70 + self_employment_income: 7000 + personal_pension_contributions: 1400 + care_hours: 40 + country: SCOTLAND + output: + carers_allowance_earnings: 6300 + carer_support_payment_pre_overlap: 0 + +- name: Post2444 working-age historical Class2 deduction control + period: 2018 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 7000 + personal_pension_contributions: 1400 + care_hours: 40 + country: SCOTLAND + output: + carers_allowance_earnings: 6146.6 + carer_support_payment_pre_overlap: 0 diff --git a/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml b/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml new file mode 100644 index 0000000000..b3ad82b939 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/carer_and_dla_minimum_ages.yaml @@ -0,0 +1,484 @@ +# Conditional age-boundary cash cases, with the existing annual age/rate convention. +# Positive carers otherwise meet the qualifying-care/disability/duration conditions, +# have no earnings/overlapping personal benefit, and are not in full-time education. +# Report-only adult cases retain an existing input proxy, not a waiver of care rules. +# DLA care categories assume the separate needs/duration conditions; infant care is +# not automatic. No final amount, pre-overlap, earnings or eligibility is supplied. +- name: Reported Carers Allowance cannot pay a fifteen year old + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Thirty five caring hours cannot pay Carers Allowance below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Reported Carers Allowance retains the existing proxy at sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: ENGLAND + output: + carers_allowance: + - 0 + - 4495.4 + carer_support_payment: + - 0 + - 0 +- name: A sixteen year old Northern Ireland carer passes the age floor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: NORTHERN_IRELAND + output: + carers_allowance: + - 0 + - 4495.4 + carer_support_payment: + - 0 + - 0 +- name: A fractional age below sixteen is not rounded up for Carers Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15.5 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: WALES + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 +- name: Scottish Carers Allowance also screens age in the retained twenty twenty four route + period: 2024 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: An eligible Scottish carer retains Carers Allowance in twenty twenty four + period: 2024 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 4258.8 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: Reported Carers Allowance cannot pay CSP below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: Thirty five caring hours cannot pay CSP below sixteen + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: CSP and its supplement retain the sixteen year old age boundary + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 4495.4 + scottish_carer_supplement: + - 0 + - 608.4 +- name: Reported receipt retains the CSP proxy at sixteen in twenty twenty five + period: 2025 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 16 + care_hours: 0 + carers_allowance_reported: 4000 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 4331.6 + scottish_carer_supplement: + - 0 + - 0 +- name: A fractional age below sixteen is not rounded up for CSP + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caree: + age: 50 + pip_dl_category: STANDARD + carer: + age: 15.5 + care_hours: 35 + benunits: + family: + members: + - caree + - carer + households: + home: + members: + - caree + - carer + country: SCOTLAND + output: + carers_allowance: + - 0 + - 0 + carer_support_payment: + - 0 + - 0 + scottish_carer_supplement: + - 0 + - 0 +- name: DLA mobility uses each child and categorys own minimum age + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caregiver: + age: 40 + higher_under: + age: 2 + dla_m_category: HIGHER + dla_sc_category: HIGHER + higher_at: + age: 3 + dla_m_category: HIGHER + lower_under: + age: 4 + dla_m_category: LOWER + dla_sc_category: HIGHER + lower_at: + age: 5 + dla_m_category: LOWER + benunits: + family: + members: + - caregiver + - higher_under + - higher_at + - lower_under + - lower_at + households: + home: + members: + - caregiver + - higher_under + - higher_at + - lower_under + - lower_at + country: ENGLAND + output: + dla_m: + - 0 + - 0 + - 4160 + - 0 + - 1575.6 + dla_sc: + - 0 + - 5959.2 + - 0 + - 5959.2 + - 0 + dla: + - 0 + - 5959.2 + - 4160 + - 5959.2 + - 1575.6 +- name: DLA care has no categorical age floor and fractional mobility ages are not rounded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + caregiver: + age: 40 + infant: + age: 1 + dla_m_category: NONE + dla_sc_category: HIGHER + higher_under: + age: 2.5 + dla_m_category: HIGHER + lower_under: + age: 4.5 + dla_m_category: LOWER + benunits: + family: + members: + - caregiver + - infant + - higher_under + - lower_under + households: + home: + members: + - caregiver + - infant + - higher_under + - lower_under + country: NORTHERN_IRELAND + output: + dla_m: + - 0 + - 0 + - 0 + - 0 + dla_sc: + - 0 + - 5959.2 + - 0 + - 0 + dla: + - 0 + - 5959.2 + - 0 + - 0 diff --git a/policyengine_uk/tests/policy/integration/carer_support_payment_age_and_earnings.yaml b/policyengine_uk/tests/policy/integration/carer_support_payment_age_and_earnings.yaml new file mode 100644 index 0000000000..a16dc65ad6 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/carer_support_payment_age_and_earnings.yaml @@ -0,0 +1,105 @@ +# Reg4 age16 AND reg14 earnings; Sch2para4 favourable penny. +# Natural pay and annual-constant-earnings approximation; no eligibility/award overrides. +- name: Combined underage low earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income: 8000 + care_hours: 40 + carers_allowance_reported: 0 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 0 + carer_support_payment: 0 +- name: Combined fractional underage reported CA route + period: 2026 + absolute_error_margin: 0.01 + input: + age: 15.999 + employment_income: 8000 + care_hours: 0 + carers_allowance_reported: 4000 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 0 + carer_support_payment: 0 +- name: Combined age16 first excluded whole weekly penny + period: 2026 + absolute_error_margin: 0.01 + input: + age: 16 + employment_income: 10608.52 + care_hours: 40 + carers_allowance_reported: 0 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 0 + carer_support_payment: 0 +- name: Combined age16 exact annual cap + period: 2026 + absolute_error_margin: 0.01 + input: + age: 16 + employment_income: 10608 + care_hours: 40 + carers_allowance_reported: 0 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 4495.4 + carer_support_payment: 4495.4 +- name: Combined age16 favourable fractional weekly penny + period: 2026 + absolute_error_margin: 0.01 + input: + age: 16 + employment_income: 10608.26 + care_hours: 40 + carers_allowance_reported: 0 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 4495.4 + carer_support_payment: 4495.4 +- name: Combined age16 ordinary below cap + period: 2026 + absolute_error_margin: 0.01 + input: + age: 16 + employment_income: 8000 + care_hours: 40 + carers_allowance_reported: 0 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 4495.4 + carer_support_payment: 4495.4 +- name: Combined personal age and earnings gates do not leak between people + period: 2026 + absolute_error_margin: 0.01 + input: + people: + young: + age: 15 + employment_income: 8000 + care_hours: 40 + high: + age: 16 + employment_income: 10608.52 + care_hours: 40 + low: + age: 16 + employment_income: 8000 + care_hours: 40 + benunits: + b1: + members: [young] + b2: + members: [high] + b3: + members: [low] + households: + home: + members: [young, high, low] + country: SCOTLAND + output: + carer_support_payment_pre_overlap: [0, 0, 4495.4] + carer_support_payment: [0, 0, 4495.4] diff --git a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py index bc505ec5de..5976d26204 100644 --- a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py @@ -11,7 +11,8 @@ benefits. ``REFERENCE`` re-adds those formulas from the parent commit under ``reference_`` names, so one simulation computes the old and the new values for the same families. They are verbatim except for the names they -read, the inlined stopgap helpers and the earnings exception's dead code. +read, the inlined stopgap helpers, the earnings exception's dead code and its +threshold, which now follows the national living wage (#2523). The reference awards read the current ``uc_deductions``, which these single-household simulations leave at nil. @@ -189,7 +190,11 @@ def formula(benunit, period, parameters): - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) - return uc_earned >= 10_152 + # The old rule's fixed 10,152 is replaced by the current threshold + # (#2523), so this compares the split, not the threshold. + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) class reference_is_benefit_cap_exempt(Variable): @@ -523,6 +528,9 @@ def example_families(): region="LONDON", benunit_inputs=dict(income_support=24_000.0), ), + # A single claimant earning between the 2025-26 and 2026-27 earnings + # thresholds (10,152 and 10,572): no earnings exception in 2026. + family(adult_inputs(40, employment_income=10_400.0)), ] @@ -845,7 +853,8 @@ def test_examples_reach_the_cases(): mixed_age, hb_lcwra, hb_floor, - ) = range(8) + earner_in_gap, + ) = range(9) # Reg. 3(3): single for UC, a couple for HB, whatever the legacy claim. for i in (uc_family, legacy_family): assert v["uc_member_of_couple_claims_as_single_person"][i] @@ -903,3 +912,7 @@ def test_examples_reach_the_cases(): ) assert abs(v["housing_benefit"][hb_floor] - 0.5 * 52) < 0.01 assert v["reference_housing_benefit"][hb_floor] == 0 + # Earnings of 10,400 are under 2026's threshold of 10,572 (#2523), and + # the reference reads the same threshold, so neither is exempt. + assert not v["is_uc_benefit_cap_exempt"][earner_in_gap] + assert not v["reference_is_benefit_cap_exempt"][earner_in_gap] diff --git a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py index 7d428435e8..9648177f44 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -940,3 +940,94 @@ def test_an_element_is_entered_exactly_when_its_last_store_was_supplied( assert is_entered(simulation, year) == entered, operations for clone_source, was_entered in earlier: assert is_entered(clone_source, year) == was_entered, operations + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("holder_role", ["claimant", "other"]) +def test_internal_lcwra_wrapper_keeps_the_model_claimant_filter( + year, active, holder_role +): + """An internal amount wrapper does not turn nonclaimant circumstances into an award.""" + from policyengine_uk.model_api import Scenario + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + claimant, other = blank("claimant", 40), blank("other", 30) + (claimant if holder_role == "claimant" else other)[ + "uc_limited_capability_for_WRA" + ] = True + sim = Simulation( + situation=situation([([claimant, other], {"working_tax_credit": 0})], year), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", year)[0] > 0 + assert not is_entered(sim, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (holder_role == "claimant") + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("amount", [0, 5_000]) +def test_internal_lcwra_wrapper_keeps_explicit_positive_and_nil_entries(active, amount): + from policyengine_uk.model_api import Scenario + + sim = Simulation( + situation=element_situation(2026, amount), + scenario=Scenario( + parameter_changes={"gov.dwp.universal_credit.rebalancing.active": active}, + applied_before_data_load=True, + ), + ) + assert is_entered(sim, 2026) + assert bool(sim.calculate(UC_SPECIFIED, 2026)[0]) == (amount > 0) + + +@pytest.mark.parametrize("year", [2025, 2026]) +@pytest.mark.parametrize("amount", [0, 6_000]) +@pytest.mark.parametrize("when", ["constructor", "after_internal_wrapper"]) +def test_internal_lcwra_wrapper_preserves_real_external_formula_origin( + year, amount, when +): + from policyengine_uk.model_api import Scenario + from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.is_uc_benefit_cap_exempt_specified_benefit import ( + calculated_by, + uc_LCWRA_element as model_lcwra_element, + ) + from policyengine_uk.scenarios.uc_reform import add_universal_credit_reform + + reform = element_reform("uc_LCWRA_element", amount) + sim = Simulation( + situation=element_situation(year), + reform=reform if when == "constructor" else None, + ) + if when == "after_internal_wrapper": + Scenario.from_reform(reform).apply(sim) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) + add_universal_credit_reform(sim) + sim.delete_arrays(UC_SPECIFIED, year) + # Origin/formula unit precondition: discard the previously model-owned + # award. This does not certify structural-reform public cache invalidation. + sim.delete_arrays("uc_LCWRA_element", year) + assert sim.calculate("uc_LCWRA_element", year)[0] == amount + assert not calculated_by( + sim.populations["benunit"], + "uc_LCWRA_element", + period(year), + model_lcwra_element, + ) + assert bool(sim.calculate(UC_SPECIFIED, year)[0]) == (amount > 0) diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py index bf211f4a82..53dc8714aa 100644 --- a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -28,6 +28,23 @@ 4. No-op: in households without non-dependants inside a benefit unit, the deductions equal the previous formulas, which charged only members of other families. +5. Local schemes: in Merton, Kingston upon Thames, Newham, Westminster and + Oxford, each non-dependant's deduction and each claiming family's total + equal an independent calculation from the council's scale: a claimant or + partner banded on the couple's joint earnings and the couple deducted once + at the higher amount (each member in Merton, Kingston upon Thames and + Westminster when the couple has Universal Credit), any other adult banded + on their own earnings, each on their own hours; the benefit-receipt + exemptions for the person on the award, Universal Credit's tested on their + own earnings (none in Oxford); and the household's pool charged to each + claiming family by its share, less its own applicant, partner and + children, unless the applicant is exempt. Every other council's variables + are zero. Conservation: the claiming families together bear the pool + times the sum of their shares, which is the whole pool wherever the + shares sum to one (they follow the regulations' per-person wording, so a + couple jointly liable with others leaves its partner's share unborne). +6. Local independence: an adult in a benefit unit who is not its claimant or + partner changes no one else's local deduction through their earnings. """ import numpy as np @@ -576,3 +593,389 @@ def other_families(amounts): np.where(ctr_exempt, 0, v["claims"] * v["ctr_share"] * pool), atol=0.01, ) + + +# 5-6. The English working-age local schemes. Each council's weekly scale for +# 2026-27 (the parameters' first values, which also apply in 2025): Merton +# para 30, Kingston upon Thames para 30A, Newham para 30B, Westminster +# Appendix A and Oxford para 43. Westminster's working-age scheme is based on +# the Default Scheme (SI 2012/2886), whose para 30(3) deducts for each member +# of a couple with an award of Universal Credit, as Merton's and Kingston upon +# Thames's texts do. +LOCAL_SCHEMES = { + "MERTON": dict( + name="merton", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=True, + each_uc_couple_member=True, + ), + "KINGSTON_UPON_THAMES": dict( + name="kingston_upon_thames", + thresholds=[0, 279, 485, 605], + amounts=[5.64, 11.50, 14.43, 17.31], + benefit_exemptions=True, + each_uc_couple_member=True, + ), + "NEWHAM": dict( + name="newham", + thresholds=[0, 183, 316, 394], + amounts=[7.54, 14.96, 18.84, 22.61], + benefit_exemptions=True, + each_uc_couple_member=False, + ), + "WESTMINSTER": dict( + name="westminster", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=True, + each_uc_couple_member=True, + ), + "OXFORD": dict( + name="oxford", + thresholds=[0, 279, 485, 605], + amounts=[5.20, 10.60, 13.30, 15.95], + benefit_exemptions=False, + each_uc_couple_member=False, + ), +} +EARNINGS = [0, 2_600, 15_600, 30_000] +HOURS = [0, 10, 37.5] + + +@st.composite +def local_family(draw, role): + # Working-age adults; the head's family has the household's oldest + # member, below State Pension age, and every extra member is at most 60. + adult_age = st.integers(61, 65) if role == "head" else st.integers(25, 59) + n = draw(st.integers(1, 2)) + + def adults(strategy): + return draw(st.lists(strategy, min_size=n, max_size=n)) + + return dict( + role=role, + adult_ages=adults(adult_age), + adult_earnings=adults(st.sampled_from(EARNINGS)), + adult_hours=adults(st.sampled_from(HOURS)), + extras=draw(st.lists(extra_member(), min_size=0, max_size=2)), + extra_students=draw(st.lists(st.booleans(), min_size=2, max_size=2)), + child=draw(st.booleans()), + renter_pip=draw(st.booleans()), + payment=draw(st.floats(1_000, 10_000, allow_nan=False)), + would_claim_uc=draw(st.booleans()), + ) + + +@st.composite +def local_household(draw): + head = draw(local_family("head")) + others = [ + draw(local_family(draw(st.sampled_from(ROLES)))) + for _ in range(draw(st.integers(0, 2))) + ] + return dict( + families=[head] + others, + rent=draw(st.floats(3_000, 20_000, allow_nan=False)), + tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), + local_authority=draw(st.sampled_from(sorted(LOCAL_SCHEMES))), + ) + + +local_population = st.lists(local_household(), min_size=1, max_size=5) + + +def build_local(population, zero_extra_earnings=False): + """build(), plus each adult's earnings and hours, each person's education + (an extra member may be a full-time student), each family's Universal + Credit claim and each household's council in England.""" + situation, person_rows, family_rows = build(population) + people = list(situation["people"].values()) + row_iter = iter(zip(people, person_rows)) + for h, house in enumerate(population): + situation["households"][f"h{h}"].update( + country="ENGLAND", local_authority=house["local_authority"] + ) + for f, fam in enumerate(house["families"]): + situation["benunits"][f"h{h}_f{f}"]["would_claim_uc"] = fam[ + "would_claim_uc" + ] + members = ( + [("adult", i) for i in range(len(fam["adult_ages"]))] + + [("extra", i) for i in range(len(fam["extras"]))] + + ([("child", 0)] if fam["child"] else []) + ) + for kind, i in members: + person, row = next(row_iter) + if kind == "adult": + earnings = fam["adult_earnings"][i] + hours = fam["adult_hours"][i] + student = False + elif kind == "extra": + earnings = person["employment_income"] + if zero_extra_earnings: + earnings = 0.0 + hours = person["weekly_hours"] + student = fam["extra_students"][i] + else: + earnings, hours, student = 0.0, 0.0, False + person.update( + employment_income=float(earnings), + weekly_hours=float(hours), + current_education="TERTIARY" if student else "NOT_IN_EDUCATION", + ) + row.update( + earnings=float(earnings), + hours=float(hours), + student=student, + claimant_or_partner=kind == "adult", + local_authority=house["local_authority"], + ) + return situation, person_rows, family_rows + + +def weekly_scale(scheme, weekly_income): + amounts = [ + amount + for threshold, amount in zip(scheme["thresholds"], scheme["amounts"]) + if weekly_income >= threshold + ] + return amounts[-1] + + +def local_oracle(person_rows, family_rows, benefits, on_award, claims, ctr_share): + """Each person's deduction under their council's scheme and each family's + total, from the generated structure, the families' Universal Credit awards + and who is on each award (on_award, read from the model).""" + n_people, n_families = len(person_rows), len(family_rows) + individual = np.zeros(n_people) + counted = np.zeros(n_people) + for p, row in enumerate(person_rows): + f = row["family"] + scheme = LOCAL_SCHEMES[row["local_authority"]] + # Reg 9: a member of a family not liable for the rent, or an adult in + # any family's benefit unit who is not its applicant, partner, child + # or young person; aged 18 or over. + eligible = row["age"] >= 18 and ( + family_rows[f]["role"] == "non_dependant" or row["kind"] == "non_dependant" + ) + if not eligible or row["student"]: + continue + couple = [ + other + for other in person_rows + if other["family"] == f and other["claimant_or_partner"] + ] + if row["claimant_or_partner"]: + income = sum(member["earnings"] for member in couple) + else: + income = row["earnings"] + weekly = ( + weekly_scale(scheme, income / 52) + if row["hours"] >= 16 + else scheme["amounts"][0] + ) + exempt = False + if scheme["benefit_exemptions"]: + # Para 30(8)(a): the person the award is payable to. Para + # 30(8)(c): one entitled to Universal Credit, on their own + # earnings. + on_income_related = any( + on_award[b][p] + for b in ( + "income_support", + "income_based_jsa", + "income_related_esa", + "pension_credit", + ) + ) + exempt = on_income_related or ( + on_award["universal_credit"][p] and row["earnings"] <= 0 + ) + individual[p] = 0 if exempt else weekly * 52 + # One deduction for a couple, the higher, or each member's in Merton, + # Kingston upon Thames and Westminster where the couple has Universal + # Credit; any other member separately. + for f, family in enumerate(family_rows): + members = [p for p, row in enumerate(person_rows) if row["family"] == f] + couple = [p for p in members if person_rows[p]["claimant_or_partner"]] + scheme = LOCAL_SCHEMES[person_rows[members[0]]["local_authority"]] + each = scheme["each_uc_couple_member"] and benefits[f]["universal_credit"] > 0 + for p in members: + if p not in couple or each: + counted[p] = individual[p] + if couple and not each: + counted[couple[0]] = max(individual[p] for p in couple) + totals = np.zeros(n_families) + pools = np.zeros(n_families) + for f, family in enumerate(family_rows): + in_household = [ + p + for p, row in enumerate(person_rows) + if family_rows[row["family"]]["household"] == family["household"] + ] + own = [ + p + for p in in_household + if person_rows[p]["family"] == f + and person_rows[p]["kind"] != "non_dependant" + ] + pools[f] = sum(counted[p] for p in in_household) + pool = pools[f] - sum(counted[p] for p in own) + # Para 30(6): no deduction from an applicant who, or whose partner, + # gets a qualifying disability benefit; another applicant in the + # household is not exempt by it. + if not family["renter_pip"]: + totals[f] = claims[f] * ctr_share[f] * pool + return individual, totals, pools + + +def run_local(population, zero_extra_earnings=False): + situation, person_rows, family_rows = build_local(population, zero_extra_earnings) + sim = Simulation(situation=situation) + universal_credit = calc(sim, "universal_credit") + benefits = [ + {"universal_credit": universal_credit[f]} for f in range(len(family_rows)) + ] + on_award = { + b: calc(sim, f"is_on_{b}") > 0 + for b in ( + "income_support", + "income_based_jsa", + "income_related_esa", + "pension_credit", + "universal_credit", + ) + } + return sim, person_rows, family_rows, benefits, on_award + + +def legal_claims_and_shares(family_rows): + """Who claims and each claim's share, from the generated structure alone. + Where a family shares the rent, every family liable for it (the head's + and each sharer's) is jointly and severally liable for the council tax and + claims, each person liable bearing an equal share of the tax and of each + shared non-dependant's deduction (para 29(3)-(4), 30(5)); otherwise the + head's family claims alone, in full.""" + n = len(family_rows) + claims, shares = np.zeros(n), np.ones(n) + for h in {family["household"] for family in family_rows}: + families = [i for i, f in enumerate(family_rows) if f["household"] == h] + liable = [i for i in families if family_rows[i]["role"] in ("head", "sharer")] + shared = any(family_rows[i]["role"] == "sharer" for i in families) + liable_people = sum(family_rows[i]["n_liable"] for i in liable) + for i in families: + if shared: + claims[i] = i in liable + shares[i] = 1 / liable_people if i in liable else 1 + else: + claims[i] = family_rows[i]["role"] == "head" + return claims, shares + + +@PROPERTY_SETTINGS +@given(local_population) +def test_local_scheme_deductions_match_the_oracle(population): + sim, person_rows, family_rows, benefits, on_award = run_local(population) + claims, ctr_share = legal_claims_and_shares(family_rows) + # The claims and shares the totals use are the scheme's, not the model's. + assert np.array_equal(calc(sim, "council_tax_reduction_claimant_benunit"), claims) + assert np.allclose( + calc(sim, "council_tax_reduction_joint_liability_share"), ctr_share + ) + individual, totals, pools = local_oracle( + person_rows, family_rows, benefits, on_award, claims, ctr_share + ) + person_council = np.array([row["local_authority"] for row in person_rows]) + family_council = np.array( + [ + person_council[[r["family"] for r in person_rows].index(f)] + for f in range(len(family_rows)) + ] + ) + for council, scheme in LOCAL_SCHEMES.items(): + name = scheme["name"] + # 5. Each council's variables follow its own scheme and are zero + # elsewhere. + assert np.allclose( + calc(sim, f"{name}_council_tax_reduction_individual_non_dep_deduction"), + np.where(person_council == council, individual, 0), + atol=0.01, + ), name + assert np.allclose( + calc(sim, f"{name}_council_tax_reduction_non_dep_deductions"), + np.where(family_council == council, totals, 0), + atol=0.01, + ), name + # Conservation: no claiming family's own applicant, partner or child has a + # deduction, so where no applicant is exempt the claiming families of a + # household together bear its pool times the sum of their shares: the + # whole pool, each non-dependant once, where the shares sum to one. + family_totals = sum( + calc(sim, f"{scheme['name']}_council_tax_reduction_non_dep_deductions") + for scheme in LOCAL_SCHEMES.values() + ) + for h in {family["household"] for family in family_rows}: + families = [i for i, f in enumerate(family_rows) if f["household"] == h] + if any(family_rows[i]["renter_pip"] for i in families): + continue + borne = sum(family_totals[i] for i in families) + shares = sum(claims[i] * ctr_share[i] for i in families) + assert np.isclose(borne, shares * pools[families[0]], atol=0.01) + if np.isclose(shares, 1): + assert np.isclose(borne, pools[families[0]], atol=0.01) + + +def with_an_earning_adult_beside_its_claimant(population): + """Each household plus a family not liable for the rent whose claimant + works 37.5 hours without earnings beside a 22-year-old earning £30,000, + and an applicant with no exemption, so the property always binds.""" + family = dict( + role="non_dependant", + adult_ages=[40], + adult_earnings=[0], + adult_hours=[37.5], + extras=[ + dict( + kind="non_dependant", + age=22, + earnings=30_000, + hours=37.5, + attendance_allowance=False, + ) + ], + extra_students=[False, False], + child=False, + renter_pip=False, + payment=1_000.0, + would_claim_uc=False, + ) + return [ + dict( + house, + families=[dict(house["families"][0], renter_pip=False)] + + house["families"][1:] + + [family], + ) + for house in population + ] + + +@PROPERTY_SETTINGS +@given(local_population) +def test_local_scheme_in_unit_earnings_affect_only_their_own_deduction(population): + population = with_an_earning_adult_beside_its_claimant(population) + sim, person_rows, *_ = run_local(population) + without, *_ = run_local(population, zero_extra_earnings=True) + # 6. Zeroing the earnings of members who are not a claimant or partner + # leaves every claimant's and partner's deduction unchanged. + claimant_or_partner = np.array([row["claimant_or_partner"] for row in person_rows]) + for scheme in LOCAL_SCHEMES.values(): + variable = ( + f"{scheme['name']}_council_tax_reduction_individual_non_dep_deduction" + ) + assert np.allclose( + calc(sim, variable)[claimant_or_partner], + calc(without, variable)[claimant_or_partner], + atol=0.01, + ), variable diff --git a/policyengine_uk/tests/test_bus_fare_parameters.py b/policyengine_uk/tests/test_bus_fare_parameters.py new file mode 100644 index 0000000000..511082de76 --- /dev/null +++ b/policyengine_uk/tests/test_bus_fare_parameters.py @@ -0,0 +1,164 @@ +"""Bus fare parameters agree with each other and with the facts they encode.""" + +import datetime +from pathlib import Path + +import pytest +import yaml + +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario +from policyengine_uk import Simulation + +BUS_PARAMETERS = Path(__file__).parents[1] / "parameters" / "gov" / "dft" / "bus" + + +def last_published_quarter(): + """The first day of the last quarter in the published fares index, and + the day after it ends.""" + raw = yaml.safe_load((BUS_PARAMETERS / "local_bus_fares_index.yaml").read_text()) + areas = {key: values for key, values in raw.items() if key.isupper()} + starts = {max(values) for values in areas.values()} + # DfT publishes every area's quarter in the same release. + assert len(starts) == 1, starts + (start,) = starts + month = start.month + 3 + end = datetime.date(start.year + month // 13, (month - 1) % 12 + 1, 1) + return start, end + + +def test_reference_cap_is_the_cap_the_published_index_reflects(): + start, end = last_published_quarter() + assert (start, end) == (datetime.date(2026, 1, 1), datetime.date(2026, 4, 1)) + bus = system.parameters.gov.dft.bus + changes = sorted( + { + datetime.date.fromisoformat(value.instant_str) + for schedule in (bus.fares.cap, bus.reference_fares.cap) + for value in schedule.values_list + } + ) + held = bus.reference_fares(start.isoformat()).cap + for day in changes + [start, end]: + reference = bus.reference_fares(day.isoformat()).cap + if day < end: + # Up to the last published quarter, the index reflects the cap + # in force. + assert reference == bus.fares(day.isoformat()).cap, day + else: + # After it, the index holds the cap of that quarter. + assert reference == held, day + + +BANDS = ("LOW", "MIDDLE", "HIGH") +SINGLE_SHARE = 0.70 +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 + + +def cpi(year): + indices = system.parameters.gov.economic_assumptions.indices.obr + return indices.consumer_price_index(f"{year}-01-01") + + +def bands(year=2024): + """(share, fare) of each band, as the model reads them in a year.""" + table = system.parameters.gov.dft.bus.uncapped_single_fares + at = f"{year}-01-01" + return [ + (table.share.children[band](at), table.fare.children[band](at)) + for band in BANDS + ] + + +def saving(cap, year=2024, price_ratio=1.0): + """Mean saving per single ticket from a cap held all year.""" + return sum(share * max(price_ratio * fare - cap, 0) for share, fare in bands(year)) + + +def test_uncapped_fare_bands(): + (low_share, low), (middle_share, middle), (high_share, high) = bands() + assert low_share + middle_share + high_share == pytest.approx(1) + # Bands at FY2024-25 prices: at or below GBP 2, up to GBP 3, above GBP 3. + assert low <= 2 < middle <= 3 < high + # Fares move with CPI after FY2024-25; shares stay. + for year in (2025, 2027): + for (share, fare), (later_share, later_fare) in zip(bands(), bands(year)): + assert later_share == share + assert later_fare == pytest.approx(fare * cpi(year) / cpi(2024)) + + +def test_table_matches_dft_cap_evaluation(): + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap and GBP 1.83 after (a GBP 0.32 + # saving); those above GBP 2 averaged GBP 2.73, which puts their share + # near 0.32 / 0.73 = 0.44. Read at face value, as the fit does. + table = bands() + above = [(share, fare) for share, fare in table if fare > 2] + above_share = sum(share for share, _ in above) + assert sum(share * fare for share, fare in table) == pytest.approx(2.15, abs=0.03) + assert above_share == pytest.approx(0.44, abs=0.05) + assert sum(share * fare for share, fare in above) / above_share == pytest.approx( + 2.73, abs=0.05 + ) + assert saving(2) == pytest.approx(0.32, abs=0.03) + + +def test_table_matches_january_2025_index_step(): + # BUS0415a outside London rose 11.0% from December 2024 to March 2025, + # when the cap rose from GBP 2 to GBP 3; neighbouring quarters moved 0.1% + # to 2.3%. The cap's part, 9% to 11% of the yield at the December 2024 + # index level (1.554716967657 * 185.9 / 190.2282). + december_yield = 1.554716967657 * 185.9 / 190.2282 + step = SINGLE_SHARE * (saving(2) - saving(3)) / december_yield + assert 0.09 <= step <= 0.11 + + +def test_table_matches_2023_24_fare_cap_grant(): + # BUS05bi fare cap support for 2023-24, a full year at GBP 2: GBP 278.95m, + # over 1,281m fare-paying boardings. Fares at 2023-24 prices. + grant = ( + SINGLE_SHARE + * FARE_PAYING_2023_24 + * saving(2, price_ratio=cpi(2023) / cpi(2024)) + ) + assert grant == pytest.approx(278_951_028, rel=0.15) + + +def test_table_matches_announced_cap_funding(): + # Over GBP 150m for the GBP 3 cap in 2025 (DfT written statement, 29 + # October 2024) and GBP 400m for the GBP 2 cap in 2027 (DfT, 22 July 2026), + # at the 2024-25 number of fare-paying boardings. + cap_3 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(3, year=2025) + cap_2 = SINGLE_SHARE * FARE_PAYING_2024_25 * saving(2, year=2027) + assert cap_3 == pytest.approx(150e6, rel=0.20) + assert cap_2 == pytest.approx(400e6, rel=0.20) + + +def test_removed_reference_year_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.reference_year": {"2025": 2025}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.reference_year has been removed" in message + assert "gov.dft.bus.benchmark_year" in message + assert "gov.dft.bus.fares.cap" in message + + +def test_removed_uncapped_single_fare_gives_directions(): + with pytest.raises(ValueError) as error: + Simulation( + situation={"people": {"person": {"age": {2025: 30}}}}, + scenario=Scenario( + parameter_changes={"gov.dft.bus.uncapped_single_fare": {"2025": 4}} + ), + ) + message = str(error.value) + assert "gov.dft.bus.uncapped_single_fare has been removed" in message + assert "gov.dft.bus.uncapped_single_fares" in message + assert "local_bus_uncapped_single_fare" in message diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py index e1614e9c38..1fd116473e 100644 --- a/policyengine_uk/tests/test_bus_fare_reforms.py +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -46,6 +46,163 @@ def bus_dataset(region="SOUTH_EAST", journeys=True, zero=False): ) +def projected_dataset( + region="SOUTH_EAST", london_trips=(0, 0), other_trips=(100, 20), fares=None +): + """A 2024 dataset that Simulation extends to later years, so journeys are + supplied in each year. A UKMultiYearDataset supplies only its own years.""" + person = pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 1], + "person_household_id": [1, 1], + "age": [30, 30], + "bus_in_london_trips": list(london_trips), + "other_local_bus_trips": list(other_trips), + "bus_pass_eligible": [False, False], + "local_bus_single_fare_share": [0.7, 0.7], + } + ) + if fares is not None: + person["local_bus_uncapped_single_fare"] = list(fares) + household = pd.DataFrame( + { + "household_id": [1], + "region": [region], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [1_500.0], + "rent": [0.0], + "household_weight": [1.0], + "bus_fare_spending": [999.0], + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=household, + fiscal_year=2024, + ) + + +def test_england_fares_follow_each_area_index(): + # Trips set so 2024-25 spending is in the ratio of published receipts + # (BUS05ai: London GBP 1,347.43m, England outside London GBP 2,069.95m). + london = 1_347.434943 / (2.369144880275 * 1.017729905895) + outside = 2_069.953713 / (1.12466920264 * 1.554716967657) + simulation = Simulation( + dataset=projected_dataset(london_trips=(london, 0), other_trips=(0, outside)) + ) + total = { + year: simulation.calculate("bus_fare_spending", year)[0] + for year in (2024, 2025) + } + assert total[2024] == pytest.approx(1_347.434943 + 2_069.953713, rel=1e-5) + # Each area moves with its own BUS0415a index, FY2025-26 over FY2024-25: + # London 196.1017 / 196.1 and England outside London 214.1797 / 190.2282. + assert total[2025] == pytest.approx( + 1_347.434943 * 1.0000086 + 2_069.953713 * 1.1259092, rel=1e-5 + ) + # So England rises 7.6%, more than DfT's England index (205.616 / 193.297, + # +6.4%). Its December 2024 to March 2025 step is 5.6%, against 11.0% + # outside London and none in London, so it weights outside London at about + # one half where receipts weight it at 61%. + assert total[2025] / total[2024] == pytest.approx(1.0763, abs=1e-4) + + +# 100 trips by one passenger outside London: 112.47 boardings, 70% singles. +SINGLES = 100 * 1.12466920264 * 0.7 + + +def spending_2025(**parameter_changes): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0)), + scenario=Scenario( + parameter_changes={ + path: {2025: value} for path, value in parameter_changes.items() + } + ), + ) + return simulation.calculate("person_bus_fare_spending", 2025)[0] + + +def test_a_three_pound_cap_binds_and_cap_reforms_follow_the_table(): + cap = "gov.dft.bus.fares.cap" + baseline = spending_2025() + uncapped = spending_2025(**{cap: np.inf}) + two_pounds = spending_2025(**{cap: 2}) + assert uncapped > baseline > two_pounds + # FY2025-26 fares are the FY2024-25 table times CPI (1.034). Only the 15% + # at GBP 3.87 is above GBP 3; a GBP 2 cap also takes the 30% at GBP 2.21 + # down to GBP 2. + assert uncapped - baseline == pytest.approx( + SINGLES * 0.15 * (3.87 * 1.034 - 3), rel=1e-3 + ) + assert baseline - two_pounds == pytest.approx( + SINGLES * (0.30 * (2.21 * 1.034 - 2) + 0.15 * 1), rel=1e-3 + ) + + +def test_scheduled_caps_enter_the_projected_baseline(): + simulation = Simulation(dataset=projected_dataset(other_trips=(100, 0))) + bus = system.parameters.gov.dft.bus + index = bus.local_bus_fares_index.ENGLAND_OUTSIDE_LONDON + table = bus.uncapped_single_fares.fare + + def index_path(year): + # The fares index projected with CPI, which holds the GBP 3 cap. + ratio = index(f"{year}-01-01") / index("2024-01-01") + return 100 * 1.12466920264 * 1.554716967657 * ratio + + def fare(band, year): + return table.children[band](f"{year}-01-01") + + def spending(year): + return simulation.calculate("person_bus_fare_spending", year)[0] + + # 2026-27: GBP 2 for the last 95 days, from 1 January 2027. + middle = fare("MIDDLE", 2026) + change = -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 95 / 365 + assert spending(2026) == pytest.approx( + index_path(2026) + SINGLES * change, rel=1e-5 + ) + # 2027-28 runs to 5 April 2028, 366 days: GBP 2 for 270, then no cap + # from 1 January 2028 for 96. + middle, high = fare("MIDDLE", 2027), fare("HIGH", 2027) + change = ( + -(0.30 * (middle - 2) + 0.15 * (3 - 2)) * 270 + 0.15 * (high - 3) * 96 + ) / 366 + assert spending(2027) == pytest.approx( + index_path(2027) + SINGLES * change, rel=1e-5 + ) + # From 2028-29 there is no cap, so the 15% above GBP 3 pay in full. + change = 0.15 * (fare("HIGH", 2028) - 3) + assert spending(2028) == pytest.approx( + index_path(2028) + SINGLES * change, rel=1e-5 + ) + + +def test_a_supplied_uncapped_fare_replaces_the_table(): + simulation = Simulation( + dataset=projected_dataset(other_trips=(100, 0), fares=(4, 4)), + scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2025: 2}}), + ) + # The dataset's fare rises with CPI like other prices (2025: +3.4%). + assert simulation.calculate("local_bus_uncapped_single_fare", 2025)[ + 0 + ] == pytest.approx(4 * 1.034) + # Every single ticket costs GBP 4.14, so a GBP 2 cap in place of GBP 3 + # saves GBP 1 on each, against GBP 0.24 on average with the table. + reformed = simulation.calculate("person_bus_fare_spending", 2025)[0] + baseline = simulation.baseline.calculate("person_bus_fare_spending", 2025)[0] + assert baseline == pytest.approx(spending_2025(), rel=1e-6) + assert baseline - reformed == pytest.approx(SINGLES * 1, rel=1e-5) + + +def test_uncapped_fare_shares_must_sum_to_one(): + with pytest.raises(ValueError, match="sum to one"): + spending_2025(**{"gov.dft.bus.uncapped_single_fares.share.HIGH": 0.5}) + + def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): simulation = Simulation( dataset=bus_dataset(), @@ -55,7 +212,10 @@ def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): k = 1_850_509_887.26131 / (28.0685543170643 * 58_620_101) fare_paying = 1_850_509_887.26131 - 519_107_608.59444 benchmark = 2_069_953_713.42079 / fare_paying - uncapped_yield = benchmark + 515_693_216.6 / fare_paying + # In 2024-25 the cap was GBP 2 for 270 days and GBP 3 for 95. Without it, + # the 30% of singles at GBP 2.21 and the 15% at GBP 3.87 pay in full. + saving = 0.30 * 0.21 * 270 / 365 + 0.15 * (1.87 * 270 / 365 + 0.87 * 95 / 365) + uncapped_yield = benchmark + 0.7 * saving baseline_people = np.array([100, 20]) * k * benchmark reformed_people = np.array([100, 20]) * k * uncapped_yield assert simulation.baseline.calculate( @@ -140,6 +300,25 @@ def test_caps_apply_before_annual_averaging_and_preserve_january_changes(): ) +def test_a_cap_change_part_way_through_a_year_keeps_its_date(): + simulation = Simulation( + dataset=bus_dataset(), + scenario=Scenario( + parameter_changes={"gov.dft.bus.fares.cap": {"2026-10-01.2100-12-31": 1}} + ), + ) + bus = simulation.tax_benefit_system.parameters.gov.dft.bus + assert bus.fares.cap.metadata["preserve_calendar_dates"] + assert bus.reference_fares.cap.metadata["preserve_calendar_dates"] + assert bus.fares("2026-09-30").cap == 3 + assert bus.fares("2026-10-01").cap == 1 + # 178 days at £3 from 6 April, then 187 at £1. Annualised, the change + # would have waited for the 2027 calendar year. + assert annual_capped_fare(4, bus.fares, 2026) == pytest.approx( + (178 * 3 + 187 * 1) / 365 + ) + + def test_clone_does_not_lose_journey_input_provenance(): original = Simulation(dataset=bus_dataset()) expected = original.calculate("bus_fare_spending", 2024)[0] diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index d79fc42b01..a74387ba6e 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -27,7 +27,9 @@ def legacy_capital_gains_tax(sim, year): there is a difference in the new formula. Its income and allowance inputs follow the current model (#2253 moved Gift Aid out of the allowances), so it checks how gains are allocated to the schedules, not equivalence with - any earlier treatment of Gift Aid. + any earlier treatment of Gift Aid. Its higher band likewise follows the + current model, which stacks gains on top of taxable income, rather than + giving every gainer the whole band as the formula before #1858 did. """ hmrc = sim.tax_benefit_system.parameters(str(year)).gov.hmrc cgt = hmrc.cgt @@ -61,9 +63,11 @@ def get(name): gains_less_aea - remaining_basic_rate_band, 0 ) higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension + # Gains stack on top of taxable income (TCGA 1992 s. 1H), so the higher + # band left for them starts at taxable income or the basic rate limit. higher_rate_applicable_cg = min_( higher_and_add_rate_applicable_cg, - higher_rate_limit - basic_rate_limit, + max_(higher_rate_limit - max_(taxable_income, basic_rate_limit), 0), ) add_rate_applicable_cg = max_( higher_and_add_rate_applicable_cg - higher_rate_applicable_cg, 0 @@ -327,9 +331,11 @@ def test_main_rate_reform_leaves_the_other_schedules_at_current_law(self): baseline = {s: self.tax(s) for s in self.SCHEDULES} reformed = {s: self.tax(s, self.MAIN_RATES_ONLY) for s in self.SCHEDULES} - # 97,000 above the AEA, all above the basic rate band: 87,440 at 40% - # and 9,560 at 45%. - assert reformed[None] == pytest.approx(39_278) + # Taxable income is 100,000 - 12,570 = 87,430, so the 97,000 above the + # AEA is all above the basic rate band. The higher band left on top of + # that income is 125,140 - 87,430 = 37,710: 37,710 at 40% and 59,290 + # at 45%. + assert reformed[None] == pytest.approx(41_764.5) for schedule in SCHEDULE_INPUTS: assert reformed[schedule] == baseline[schedule], schedule @@ -337,7 +343,86 @@ def test_setting_every_schedule_taxes_every_gain_alike(self): reformed = {s: self.tax(s, self.EVERY_SCHEDULE) for s in self.SCHEDULES} for schedule in self.SCHEDULES: - assert reformed[schedule] == pytest.approx(39_278), schedule + assert reformed[schedule] == pytest.approx(41_764.5), schedule + + +class TestExemptionAllocation: + """The annual exempt amount goes where it saves most (TCGA 1992 s. 1K(5)). + + Gains stack on taxable income, so a schedule's top gains can be at its + additional rate. The exemption is set for each person, pound by pound, + against the gains where it saves most, so it moves to another schedule + once a schedule's gains fall to a lower rate. + """ + + YEAR = 2025 + # The tax on gains held fixed shows where the exemption goes, so the + # realisation response is switched off. + ADDITIONAL_RATE_ONLY = { + "gov.hmrc.cgt.additional_rate": {"2025": 0.45}, + "gov.simulation.capital_gains_responses.elasticity": {"2025": 0}, + } + + def test_exemption_follows_each_persons_top_rate(self): + people, benunits, households = {}, {}, {} + for i, income in enumerate((200_000, 100_000)): + people[f"person_{i}"] = { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: income}, + "capital_gains": {self.YEAR: 50_000}, + "capital_gains_residential_property": {self.YEAR: 25_000}, + } + benunits[f"benunit_{i}"] = {"members": [f"person_{i}"]} + households[f"household_{i}"] = {"members": [f"person_{i}"]} + sim = Microsimulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 200,000 is above the higher rate limit, so main + # gains are at the reformed 45% and residential gains at 24%. The + # 3,000 exemption goes against main gains: 25,000 x 0.24 + 22,000 x + # 0.45 = 15,900, not 22,000 x 0.24 + 25,000 x 0.45 = 16,530. + assert tax[0] == pytest.approx(15_900) + # Taxable income 87,430 leaves 37,710 of higher band. Main gains fill + # it first, so none reach the 45% rate and every gain is at 24%: + # 47,000 x 0.24, wherever the exemption goes. + assert tax[1] == pytest.approx(11_280) + + def test_exemption_moves_on_when_the_marginal_rate_falls(self): + sim = Microsimulation( + situation={ + "people": { + "person": { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: 124_000}, + "capital_gains": {self.YEAR: 27_000}, + "capital_gains_carried_interest": {self.YEAR: 25_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {self.YEAR: "LONDON"}, + } + }, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 123,430 leaves 1,710 of higher band, so 290 of the + # 2,000 main gains are at 45% and the rest at 24%; carried interest + # is at 32%. The exemption takes the 290 at 45%, then 2,710 of + # carried interest: 1,710 x 0.24 + 22,290 x 0.32 = 7,543.20, not + # 7,680 from exempting all the main gains first. + assert tax[0] == pytest.approx(7_543.20) class TestScheduleParameters: diff --git a/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py b/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py new file mode 100644 index 0000000000..9f4166cb42 --- /dev/null +++ b/policyengine_uk/tests/test_carer_and_dla_minimum_ages.py @@ -0,0 +1,224 @@ +"""Minimum-age cash/override contracts using the existing annual FLOAT age. + +Other qualifying-care/disability/duration conditions are assumed. These tests +preserve the category/reported-receipt proxies; they do not audit eligibility. +""" + +from pathlib import Path + +import pytest + +from policyengine_core.parameters.helpers import load_parameter_file +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +YEAR = 2026 + + +def situation(person, country="ENGLAND"): + return { + "people": { + "caregiver": {"age": {YEAR: 50}, "pip_dl_category": {YEAR: "STANDARD"}}, + "person": {name: {YEAR: value} for name, value in person.items()}, + }, + "benunits": {"family": {"members": ["caregiver", "person"]}}, + "households": { + "home": {"members": ["caregiver", "person"], "country": {YEAR: country}} + }, + } + + +@pytest.mark.parametrize( + "path,first,minimum", + [ + ("gov.dwp.carers_allowance.min_age", "2015-01-01", 16), + ( + "gov.social_security_scotland.carer_support_payment.min_age", + "2023-11-19", + 16, + ), + ("gov.dwp.dla.mobility.higher_min_age", "2001-04-09", 3), + ("gov.dwp.dla.mobility.lower_min_age", "2001-04-09", 5), + ], +) +def test_raw_age_dates_and_supported_annual_parameters(path, first, minimum): + parameter_file = ( + Path(__file__).resolve().parents[1] + / "parameters" + / (path.replace(".", "/") + ".yaml") + ) + raw = load_parameter_file(str(parameter_file), name=path) + assert raw.values_list[-1].instant_str == first + assert raw(first) == minimum + assert raw("2040-01-01") == minimum + # CA's first date is model coverage, not asserted legal commencement. + # Statutory CSP dates do not expand the existing annual replacement rule. + system = CountryTaxBenefitSystem() + for year in [2015, 2024, 2025, 2026, 2040]: + node = system.parameters(str(year)) + for part in path.split("."): + node = getattr(node, part) + assert node == minimum + + +@pytest.mark.parametrize("year", [2015, 2024, 2025]) +def test_age_gate_keeps_supported_annual_csp_and_ca_routing(year): + inputs = situation({"age": 15, "care_hours": 35}, "SCOTLAND") + for person in inputs["people"].values(): + for name, value in person.items(): + person[name] = {year: value[YEAR]} + inputs["households"]["home"]["country"] = {year: "SCOTLAND"} + sim = Simulation(situation=inputs) + assert sim.calculate("carers_allowance", year)[1] == 0 + assert sim.calculate("carer_support_payment", year)[1] == 0 + + +@pytest.mark.parametrize( + "parameter,raised,person,country,output,baseline,other_person,other_country,other_output,other_cash", + [ + ( + "gov.dwp.carers_allowance.min_age", + 18, + {"age": 17, "care_hours": 35}, + "ENGLAND", + "carers_allowance", + 4495.40, + {"age": 17, "care_hours": 35}, + "SCOTLAND", + "carer_support_payment", + 4495.40, + ), + ( + "gov.social_security_scotland.carer_support_payment.min_age", + 18, + {"age": 17, "care_hours": 35}, + "SCOTLAND", + "carer_support_payment", + 4495.40, + {"age": 17, "care_hours": 35}, + "ENGLAND", + "carers_allowance", + 4495.40, + ), + ( + "gov.dwp.dla.mobility.higher_min_age", + 4, + {"age": 3, "dla_m_category": "HIGHER"}, + "ENGLAND", + "dla_m", + 4160, + {"age": 5, "dla_m_category": "LOWER"}, + "ENGLAND", + "dla_m", + 1575.60, + ), + ( + "gov.dwp.dla.mobility.lower_min_age", + 6, + {"age": 5, "dla_m_category": "LOWER"}, + "ENGLAND", + "dla_m", + 1575.60, + {"age": 3, "dla_m_category": "HIGHER"}, + "ENGLAND", + "dla_m", + 4160, + ), + ], +) +def test_each_minimum_age_reform_moves_only_its_cash_boundary( + parameter, + raised, + person, + country, + output, + baseline, + other_person, + other_country, + other_output, + other_cash, +): + original = Simulation(situation=situation(person, country)) + reformed = Simulation( + situation=situation(person, country), reform={parameter: raised} + ) + assert original.calculate(output, YEAR)[1] == pytest.approx(baseline, abs=0.01) + assert reformed.calculate(output, YEAR)[1] == 0 + countercontrol = Simulation( + situation=situation(other_person, other_country), reform={parameter: raised} + ) + assert countercontrol.calculate(other_output, YEAR)[1] == pytest.approx( + other_cash, abs=0.01 + ) + + +@pytest.mark.parametrize( + "input_name,output,amount,country,age", + [ + ("carers_allowance", "carers_allowance", 123, "ENGLAND", 15), + ("carers_allowance_pre_overlap", "carers_allowance", 234, "ENGLAND", 15), + ("carer_support_payment", "carer_support_payment", 345, "SCOTLAND", 15), + ( + "carer_support_payment_pre_overlap", + "carer_support_payment", + 456, + "SCOTLAND", + 15, + ), + ("dla_m", "dla", 567, "ENGLAND", 1), + ], +) +def test_explicit_amount_overrides_keep_the_generic_input_contract( + input_name, output, amount, country, age +): + # A supplied amount is intentionally an override, not entitlement proof. + sim = Simulation(situation=situation({"age": age, input_name: amount}, country)) + assert sim.calculate(output, YEAR)[1] == amount + + +def test_missing_age_keeps_the_existing_default_and_none_category_pays_nothing(): + sim = Simulation( + situation=situation({"care_hours": 35, "dla_m_category": "HIGHER"}) + ) + assert sim.calculate("age", YEAR)[1] == 40 + assert sim.calculate("carers_allowance", YEAR)[1] == pytest.approx( + 4495.40, abs=0.01 + ) + assert sim.calculate("dla_m", YEAR)[1] == 4160 + none = Simulation(situation=situation({"age": 40, "dla_m_category": "NONE"})) + assert none.calculate("dla_m", YEAR)[1] == 0 + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_each_persons_age_controls_cash_across_families_and_request_order(reverse): + people, benunits, households = {}, {}, {} + specs = [ + ("english", "ENGLAND", 15, 3), + ("scottish", "SCOTLAND", 16, 2), + ] + for family, country, carer_age, child_age in specs: + names = [family + suffix for suffix in ["_caree", "_carer", "_child"]] + facts = [ + {"age": 50, "pip_dl_category": "STANDARD"}, + {"age": carer_age, "care_hours": 35}, + {"age": child_age, "dla_m_category": "HIGHER"}, + ] + for name, fact in zip(names, facts): + people[name] = {key: {YEAR: value} for key, value in fact.items()} + members = list(reversed(names)) if reverse else names + benunits[family] = {"members": members} + households[family] = {"members": members, "country": {YEAR: country}} + if reverse: + people = dict(reversed(list(people.items()))) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + if reverse: + sim.calculate("is_entitled_to_carer_benefit", YEAR) + for variable, expected in [ + ("carers_allowance", {}), + ("carer_support_payment", {"scottish_carer": 4495.40}), + ("dla", {"english_child": 4160}), + ]: + values = dict(zip(sim.populations["person"].ids, sim.calculate(variable, YEAR))) + for name, value in values.items(): + assert value == pytest.approx(expected.get(name, 0), abs=0.01) diff --git a/policyengine_uk/tests/test_carer_support_payment.py b/policyengine_uk/tests/test_carer_support_payment.py index c833bfae38..f75a148796 100644 --- a/policyengine_uk/tests/test_carer_support_payment.py +++ b/policyengine_uk/tests/test_carer_support_payment.py @@ -140,3 +140,146 @@ def test_csp_counts_for_uc_non_dep_exemption_and_housing_benefit_income(): assert sim.calculate("uc_non_dep_deduction_exempt", YEAR_2025)[0] assert hb_income == max(0, csp_amount - hb_disregard) + + +def test_csp_earnings_limit_is_independent_of_carers_allowance_limit(): + year = 2026 + inputs = _situation(year, employment_income={year: 11_000}) + baseline = Simulation(situation=inputs) + csp_reform = Simulation( + situation=inputs, + reform={ + "gov.social_security_scotland.carer_support_payment.earnings_limit": 1_000 + }, + ) + ca_reform = Simulation( + situation=inputs, + reform={"gov.dwp.carers_allowance.earnings_limit": 1_000}, + ) + assert baseline.calculate("carer_support_payment", year)[0] == 0 + assert csp_reform.calculate("carer_support_payment", year)[0] == pytest.approx( + 4_495.40, abs=0.01 + ) + assert ca_reform.calculate("carer_support_payment", year)[0] == 0 + + # A CSP parameter reform cannot change the existing England CA route. + inputs["households"]["household"]["country"] = {year: "ENGLAND"} + england = Simulation( + situation=inputs, + reform={ + "gov.social_security_scotland.carer_support_payment.earnings_limit": 1_000 + }, + ) + assert england.calculate("carers_allowance", year)[0] == 0 + + +def test_csp_statutory_dates_and_annual_model_coverage(): + from pathlib import Path + + from policyengine_core.parameters.helpers import load_parameter_file + from policyengine_uk import CountryTaxBenefitSystem + + parameter_file = ( + Path(__file__).resolve().parents[1] + / "parameters/gov/social_security_scotland/carer_support_payment/earnings_limit.yaml" + ) + raw = load_parameter_file(str(parameter_file), name="earnings_limit") + for date, expected in [ + ("2023-11-19", 139), + ("2024-04-06", 139), + ("2024-04-07", 151), + ("2025-04-05", 151), + ("2025-04-06", 196), + ("2026-04-04", 196), + ("2026-04-05", 204), + ]: + assert raw(date) == expected + system = CountryTaxBenefitSystem() + for year, expected in [(2024, 151), (2025, 196), (2026, 204)]: + assert ( + system.parameters( + str(year) + ).gov.social_security_scotland.carer_support_payment.earnings_limit + == expected + ) + # The statutory pilot start does not expand the existing model coverage. + for year in [2023, 2024]: + sim = Simulation(situation=_situation(year)) + assert sim.calculate("carer_support_payment", year)[0] == 0 + + +def test_csp_first_excluded_penny_in_the_actual_holder_dtype(record_property): + import json + + import numpy as np + + year = 2026 + critical = Simulation( + situation=_situation(year, employment_income={year: 10_608.52}) + ) + earnings = critical.calculate("carers_allowance_earnings", year) + encoded_boundary = np.asarray(10_608.52, dtype=earnings.dtype) + assert critical.calculate("carer_support_payment", year)[0] == 0 + observations = [] + # These neighbours check the model's encoded legal penny boundary, rather + # than claim that arbitrary binary fractions are a separate statutory rule. + for pay, expected in [ + (np.nextafter(encoded_boundary, -np.inf, dtype=earnings.dtype), 4_495.40), + (encoded_boundary, 0), + (np.nextafter(encoded_boundary, np.inf, dtype=earnings.dtype), 0), + ]: + sim = Simulation( + situation=_situation(year, employment_income={year: float(pay)}) + ) + counted = sim.calculate("carers_allowance_earnings", year) + actual = sim.calculate("carer_support_payment", year)[0] + assert actual == pytest.approx(expected, abs=0.01) + observations.append( + { + "pay": float(pay), + "counted_annual": float(counted[0]), + "dtype": str(counted.dtype), + "CSP": float(actual), + "expected": expected, + } + ) + record_property("CSP_holder_boundary", json.dumps(observations, sort_keys=True)) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_csp_earnings_are_personal_and_request_order_independent(reverse): + year = 2026 + people, benunits, households = {}, {}, {} + for family, pay in [("low", 8_000), ("high", 40_000)]: + names = [family + suffix for suffix in ["_carer", "_partner", "_caree"]] + people[names[0]] = { + "age": {year: 45}, + "care_hours": {year: 40}, + "employment_income": {year: pay}, + "is_parent": {year: True}, + } + people[names[1]] = { + "age": {year: 43}, + "employment_income": {year: 40_000 if family == "low" else 0}, + "is_parent": {year: True}, + } + people[names[2]] = {"age": {year: 18}, "pip_dl_category": {year: "STANDARD"}} + benunits[family] = {"members": list(reversed(names)) if reverse else names} + households[family] = { + "members": list(reversed(names)) if reverse else names, + "country": {year: "SCOTLAND"}, + } + if reverse: + people = dict(reversed(list(people.items()))) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + if reverse: + sim.calculate("is_entitled_to_carer_benefit", year) + values = sim.calculate("carer_support_payment", year) + by_name = dict(zip(sim.populations["person"].ids, values)) + assert by_name["low_carer"] == pytest.approx(4_495.40, abs=0.01) + assert by_name["high_carer"] == 0 + assert all( + value == 0 for name, value in by_name.items() if not name.endswith("_carer") + ) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py index 497d9ad7f9..146a82091e 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -14,8 +14,11 @@ 1. Bounds: 0 <= carers_allowance_earnings <= pay + max(0, profit). 2. Differential: the test is passed exactly when - carers_allowance_earnings / 52 <= 204, and the allowance is the full rate - then and nil otherwise. + carers_allowance_earnings / 52, with a fraction of a penny disregarded + (Computation of Earnings Regulations 1996 reg. 5), is at most 204, and the + allowance is the full rate then and nil otherwise. The pence are counted + in the precision the model stores earnings in (float32), as the formula + counts them. 3. Monotonic: raising pay never lowers net earnings, so a person over the limit stays over it. 4. Unearned income is not earnings: savings interest and pension income @@ -24,7 +27,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -54,8 +57,10 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): }, } sim = Simulation(situation=situation) + # Keep each variable's stored dtype: the reg. 5 check must count pence in + # the precision the formula does. Tolerance checks convert with float(). return { - name: float(np.asarray(sim.calculate(name, 2026))[0]) + name: np.asarray(sim.calculate(name, 2026))[0] for name in ( "carers_allowance_earnings", "is_gainfully_employed_for_carers_allowance", @@ -64,6 +69,15 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): } +def _over_limit(earnings): + # Regulation 5: a fraction of a penny is disregarded. ``earnings`` is the + # stored value, so this counts the pence as + # is_gainfully_employed_for_carers_allowance does, in float32: promoting + # to float64 first floors 10,608.52 a year, stored as 10,608.51953125, to + # 204.00 a week instead of 204.01. + return np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + + money = st.floats(min_value=0, max_value=60_000, allow_nan=False) profit_money = st.floats(min_value=-20_000, max_value=60_000, allow_nan=False) @@ -71,24 +85,46 @@ def _person(pay, profit, pension, savings=0.0, private_pension=0.0): @settings( max_examples=12, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, profit=profit_money, pension_share=st.floats(0, 0.3)) +# Exactly at the 2026-27 limit (10,608 / 52 = 204.00): not gainfully employed. +@example(pay=10_608.0, profit=0.0, pension_share=0.0) +# One penny a week over it (10,608.52 / 52 = 204.01), on the float32 boundary +# described in _over_limit: gainfully employed. +@example(pay=10_608.52, profit=0.0, pension_share=0.0) +# A trading loss, with earnings below the limit: the derandomized draws have +# no real loss and no earnings between a few pence and the limit. +@example(pay=8_000.0, profit=-5_000.0, pension_share=0.1) def test_bounds_and_differential(pay, profit, pension_share): pension = pay * pension_share result = _person(pay, profit, pension) earnings = result["carers_allowance_earnings"] - assert -0.01 <= earnings <= pay + max(0, profit) + 0.01 - # Regulation 5: a fraction of a penny is disregarded. - over = np.floor(earnings / WEEKS * 100 + 1e-6) / 100 > LIMIT + assert -0.01 <= float(earnings) <= pay + max(0, profit) + 0.01 + over = _over_limit(earnings) assert bool(result["is_gainfully_employed_for_carers_allowance"]) == over expected = 0 if over else RATE * WEEKS - assert abs(result["carers_allowance"] - expected) < 0.01 + assert abs(float(result["carers_allowance"]) - expected) < 0.01 + + +@pytest.mark.parametrize("pay, gainful", [(10_608.00, False), (10_608.52, True)]) +def test_a_penny_a_week_over_the_2026_limit_ends_entitlement(pay, gainful): + """£10,608 a year is £204.00 a week, at the limit; £10,608.52 is £204.01, a + penny over it. This pins the outcome for the amount entered, not only + agreement with _over_limit. The model stores £10,608.52 as 10,608.51953125 + (£204.00 a week in exact arithmetic) and reaches £204.01 through float32 + rounding; see #2541.""" + result = _person(pay, 0, 0) + assert bool(result["is_gainfully_employed_for_carers_allowance"]) == gainful + expected = 0 if gainful else RATE * WEEKS + assert abs(float(result["carers_allowance"]) - expected) < 0.01 @settings( max_examples=8, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given(pay=money, rise=st.floats(min_value=1, max_value=20_000)) @@ -96,7 +132,8 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): lower = _person(pay, 0, 0) higher = _person(pay + rise, 0, 0) assert ( - higher["carers_allowance_earnings"] >= lower["carers_allowance_earnings"] - 0.01 + float(higher["carers_allowance_earnings"]) + >= float(lower["carers_allowance_earnings"]) - 0.01 ) if lower["is_gainfully_employed_for_carers_allowance"]: assert higher["is_gainfully_employed_for_carers_allowance"] @@ -105,6 +142,7 @@ def test_net_earnings_never_fall_as_pay_rises(pay, rise): @settings( max_examples=6, deadline=None, + derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) @given( @@ -117,8 +155,8 @@ def test_unearned_income_is_not_earnings(pay, savings, private_pension): with_unearned = _person(pay, 0, 0, savings=savings, private_pension=private_pension) assert ( abs( - alone["carers_allowance_earnings"] - - with_unearned["carers_allowance_earnings"] + float(alone["carers_allowance_earnings"]) + - float(with_unearned["carers_allowance_earnings"]) ) < 0.01 ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py new file mode 100644 index 0000000000..3bfc7883ef --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -0,0 +1,466 @@ +"""IIDB, SDA, incapacity benefit and war pensions in Council Tax Reduction income. + +Council Tax Reduction income left out industrial injuries disablement benefit +(IIDB), severe disablement allowance (SDA) and incapacity benefit (IB), which +every scheme the model runs counts: + +- England, pensioners: SI 2012/2885 Sch 1 para 16(1)(j), social security + benefits other than those it lists; (j)(v) excepts only IIDB's increases + under SSCBA 1992 ss.104 and 105. +- English working-age schemes: gross income (SI 2012/2886 Sch para 54(1)) + less Sch 8, which disregards none of the three; Kingston upon Thames, Merton + and Newham copy it, and Oxford counts income other than earnings unless its + Appendix 5 lists it. +- Wales: WSI 2013/3029 Sch 1 para 10(1)(j) and Sch 6 para 17(1) with Sch 9. +- Scotland: SSI 2012/319 reg 27(1)(j) and SSI 2021/249 reg 57(1)(b)(ix)-(xi). + +Pension Credit counts them too (SPC Regs 2002 reg 15(1)), so once the +guarantee credit passport ends, Council Tax Reduction was assessed on less +income than Pension Credit and Housing Benefit. + +Only the applicant's and partner's benefits count (SI 2012/2885 Sch 1 para 11; +SSI 2021/249 reg 36(1)), not a dependant's. + +War pensions and Armed Forces Compensation Scheme payments stay uncounted. +Scotland disregards or omits them (SSI 2012/319 Sch 3 para 1; SSI 2021/249 reg +57(1)), and English and Welsh councils may relieve more than the prescribed +£10 a week (WSI 2013/3029 reg 34(5); LGFA 1992 Sch 1A para 2(11) and +s.13A(1)(c)). The councils whose schemes the model reads mostly do: Westminster +and Oxford disregard them in full, Newham's discretionary policy adds what a +full disregard would give, and Kingston upon Thames's and Merton's texts point +both ways. The model approximates all of them with the full disregard. + +Invariants, in 2026, for single people and couples not claiming Universal +Credit (whose award would move with the benefits), with no children except in +invariant 4: + +1. Differential: Council Tax Reduction and Pension Credit assess the same + income. For pension-age families under the national pensioner schemes in + England, Wales and Scotland, with State Pension, private pension, IIDB, IB + and SDA, no earnings, no pension contributions and savings of at most + £10,000 (so neither scheme's deemed income from capital applies): + Council Tax Reduction income is nil where guarantee credit is received + (SI 2012/2885 Sch 1 para 13 and equivalents), Pension Credit income plus + the savings credit where the award is savings credit only (para 14), and + Pension Credit income otherwise. +2. Pound for pound, every scheme: raising IIDB or IB by d, or awarding SDA, + raises Council Tax Reduction income plus the family's income tax and + National Insurance by exactly the added amount, wherever the general rules + apply before and after and the zero floor does not bind. The reduction + never rises where the route (general rules, savings credit only, guarantee + credit) is unchanged; in the national schemes, where both awards are + strictly between nil and the liability, it falls by 20% of the rise in + income. +3. War pensions: adding war pensions and Armed Forces Compensation Scheme + payments leaves Council Tax Reduction income unchanged under the general + rules, in every scheme. +4. Dependants: giving a dependant aged 16 to 19 IIDB, IB within the personal + allowance, or SDA leaves Council Tax Reduction income unchanged under the + general rules, in every scheme. + +These compare the model's own measures. Invariant 1 relies on both measures +deducting the family's whole income tax and National Insurance, an existing +approximation in each. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +# 2026-27 weekly rates (SI 2026/148 Sch 1). +IIDB_FULL = 233.90 * WEEKS +LONG_TERM_IB = 146.60 * WEEKS +SDA = (103.85 + 15.50) * WEEKS +WITHDRAWAL_RATE = 0.2 +PRIVATE_PENSIONS = np.arange(0, 20_001, 1_000) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Areas of the national pensioner schemes, and of the working-age schemes. +PENSION_AGE_AREAS = { + "MAIDSTONE": ("ENGLAND", "SOUTH_EAST"), + "KINGSTON_UPON_THAMES": ("ENGLAND", "LONDON"), + "NEWHAM": ("ENGLAND", "LONDON"), + "CARDIFF": ("WALES", "WALES"), + "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), +} +WORKING_AGE_AREAS = { + "WESTMINSTER": ("ENGLAND", "LONDON"), + "MERTON": ("ENGLAND", "LONDON"), + "KINGSTON_UPON_THAMES": ("ENGLAND", "LONDON"), + "NEWHAM": ("ENGLAND", "LONDON"), + "OXFORD": ("ENGLAND", "SOUTH_EAST"), + "CARDIFF": ("WALES", "WALES"), + "GLASGOW_CITY": ("SCOTLAND", "SCOTLAND"), +} +NATIONAL_SCHEME_AREAS = {"MAIDSTONE", "CARDIFF", "GLASGOW_CITY"} +BENUNIT_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_relevant_income_based_benefit", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + "pension_credit_income", + "pension_credit", +] +PERSON_TOTALS = ["income_tax", "national_insurance"] + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def benefits(draw): + """IIDB at a 20% to 100% assessment, IB up to the long-term rate, SDA.""" + return dict( + iidb=draw(st.one_of(st.just(0.0), money(IIDB_FULL))), + incapacity_benefit=draw(st.one_of(st.just(0.0), money(LONG_TERM_IB))), + sda=draw(st.booleans()), + ) + + +@st.composite +def pension_age_family(draw, areas=tuple(PENSION_AGE_AREAS)): + adults = draw(st.integers(1, 2)) + return dict( + ages=[draw(st.integers(67, 95)) for _ in range(adults)], + state_pensions=[draw(money(15_000)) for _ in range(adults)], + earnings=[0.0] * adults, + benefits=[draw(benefits()) for _ in range(adults)], + property_income=draw(st.one_of(st.just(0.0), money(5_000))), + area=draw(st.sampled_from(areas)), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + would_claim_pc=draw(st.booleans()), + ) + + +@st.composite +def working_age_family(draw): + adults = draw(st.integers(1, 2)) + return dict( + ages=[draw(st.integers(25, 60)) for _ in range(adults)], + state_pensions=[0.0] * adults, + earnings=[draw(st.one_of(st.just(0.0), money(30_000))) for _ in range(adults)], + benefits=[draw(benefits()) for _ in range(adults)], + property_income=draw(st.one_of(st.just(0.0), money(5_000))), + area=draw(st.sampled_from(tuple(WORKING_AGE_AREAS))), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), money(5_000))), + would_claim_pc=False, + ) + + +any_family = st.one_of(pension_age_family(), working_age_family()) + +# A single pensioner off Pension Credit with every benefit and State Pension +# below the personal allowance, so the general rules apply in every cell. +GENERAL_RULES_PENSIONER = dict( + ages=[75], + state_pensions=[9_000.0], + earnings=[0.0], + benefits=[dict(iidb=IIDB_FULL * 0.5, incapacity_benefit=LONG_TERM_IB, sda=True)], + property_income=0.0, + area="MAIDSTONE", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) +# A pensioner whose reduction is partial before and after each change, so the +# 20% taper in invariant 2 is checked. +PARTIAL_AWARD_PENSIONER = dict( + ages=[70], + state_pensions=[241.30 * WEEKS], + earnings=[0.0], + benefits=[dict(iidb=0.0, incapacity_benefit=0.0, sda=False)], + property_income=0.0, + area="MAIDSTONE", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) +# A working-age couple on the Kingston upon Thames scheme. +KINGSTON_COUPLE = dict( + ages=[40, 40], + state_pensions=[0.0, 0.0], + earnings=[8_000.0, 0.0], + benefits=[dict(iidb=IIDB_FULL * 0.3, incapacity_benefit=0.0, sda=False)] * 2, + property_income=0.0, + area="KINGSTON_UPON_THAMES", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) + + +# A working-age lone adult in Westminster, close in age to a dependant. +YOUNG_PARENT = dict( + ages=[32], + state_pensions=[0.0], + earnings=[12_000.0], + benefits=[dict(iidb=0.0, incapacity_benefit=0.0, sda=False)], + property_income=0.0, + area="WESTMINSTER", + council_tax=2_000.0, + savings=0.0, + would_claim_pc=False, +) + + +def area_of(fam): + if fam["ages"][0] >= 67: + return PENSION_AGE_AREAS[fam["area"]] + return WORKING_AGE_AREAS[fam["area"]] + + +def situation(variants): + """One benefit unit and household per (family, private pension, change). + + variants: list of (family, private pension of the first adult, extra + person inputs for the first adult). + """ + people, benunits, households = {}, {}, {} + for i, (fam, private_pension, extra) in enumerate(variants): + country, region = area_of(fam) + names = [] + for j, age in enumerate(fam["ages"]): + name = f"p{i}_{j}" + b = fam["benefits"][j] + person = { + "age": {YEAR: age}, + # The generated adults are the claimant and partner, and any + # dependant is not: say so for everyone, so the model's + # presumption (a member under 20 and 16 or more years younger + # than the claimant is their child) cannot read a dependant + # close in age to the claimant as a partner. + "is_claimant_or_partner": {YEAR: True}, + "state_pension": {YEAR: fam["state_pensions"][j]}, + "employment_income": {YEAR: fam["earnings"][j]}, + "private_pension_income": {YEAR: private_pension * (j == 0)}, + "property_income": {YEAR: fam["property_income"] * (j == 0)}, + "iidb_reported": {YEAR: b["iidb"]}, + "incapacity_benefit_reported": {YEAR: b["incapacity_benefit"]}, + "sda_reported": {YEAR: float(b["sda"])}, + } + for variable, value in extra.get(j, {}).items(): + person[variable] = {YEAR: value} + people[name] = person + names.append(name) + if fam.get("dependant_age") is not None: + j = len(fam["ages"]) + name = f"p{i}_{j}" + people[name] = { + "age": {YEAR: fam["dependant_age"]}, + "is_claimant_or_partner": {YEAR: False}, + } + for variable, value in extra.get(j, {}).items(): + people[name][variable] = {YEAR: value} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {YEAR: fam["would_claim_pc"]}, + "would_claim_uc": {YEAR: False}, + } + households[f"h{i}"] = { + "members": names, + "country": {YEAR: country}, + "region": {YEAR: region}, + "local_authority": {YEAR: fam["area"]}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(variants): + simulation = Simulation(situation=situation(variants)) + values = {v: np.asarray(simulation.calculate(v, YEAR)) for v in BENUNIT_VARIABLES} + values["tax"] = sum( + np.asarray(simulation.calculate(v, YEAR, map_to="benunit")) + for v in PERSON_TOTALS + ) + values["liability"] = np.asarray( + simulation.calculate("council_tax_reduction_maximum_eligible_liability", YEAR) + ) + return values + + +def route(values): + """0: general rules; 1: savings credit only; 2: guarantee credit.""" + gc = values["in_receipt_of_guarantee_credit"].astype(bool) + sc_only = values["in_receipt_of_savings_credit_only"].astype(bool) + return np.where(gc, 2, np.where(sc_only, 1, 0)) + + +@PROPERTY_SETTINGS +@given(st.lists(pension_age_family(), min_size=1, max_size=4)) +@example([GENERAL_RULES_PENSIONER]) +def test_council_tax_reduction_and_pension_credit_assess_the_same_income( + families, +): + variants = [(fam, p, {}) for fam in families for p in PRIVATE_PENSIONS] + v = calculate(variants) + pensioner = v["council_tax_reduction_pensioner"].astype(bool) + assert np.all(pensioner) + ctr_income = v["council_tax_reduction_applicable_income"] + pc_income = v["pension_credit_income"] + r = route(v) + expected = np.select( + [r == 2, r == 1], [0.0, pc_income + v["pension_credit"]], default=pc_income + ) + assert np.allclose(ctr_income, expected, atol=0.01), [ + (variants[k][0], variants[k][1], ctr_income[k], expected[k], r[k]) + for k in np.flatnonzero(~np.isclose(ctr_income, expected, atol=0.01))[:3] + ] + if families == [GENERAL_RULES_PENSIONER]: + # Not vacuous: the general rules apply and count every benefit. + assert np.all(r == 0) + b = GENERAL_RULES_PENSIONER["benefits"][0] + counted = 9_000 + b["iidb"] + b["incapacity_benefit"] + SDA + assert np.isclose(ctr_income[0], counted - v["tax"][0], atol=0.01) + + +CHANGES = { + "iidb": lambda b: {"iidb_reported": b["iidb"] + 0.2 * IIDB_FULL}, + "incapacity_benefit": lambda b: { + "incapacity_benefit_reported": b["incapacity_benefit"] + 0.3 * LONG_TERM_IB + }, + "sda": lambda b: {"sda_reported": 1.0}, +} +ADDED = { + "iidb": lambda b: 0.2 * IIDB_FULL, + "incapacity_benefit": lambda b: 0.3 * LONG_TERM_IB, + "sda": lambda b: 0.0 if b["sda"] else SDA, +} + + +@PROPERTY_SETTINGS +@given( + st.lists(any_family, min_size=1, max_size=6), + st.sampled_from(sorted(CHANGES)), +) +@example( + [GENERAL_RULES_PENSIONER, KINGSTON_COUPLE, PARTIAL_AWARD_PENSIONER], + "incapacity_benefit", +) +@example([PARTIAL_AWARD_PENSIONER], "iidb") +def test_each_benefit_counts_pound_for_pound_in_every_scheme(families, change): + variants = [] + for fam in families: + b = fam["benefits"][0] + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, {0: CHANGES[change](b)})) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + added = np.array([ADDED[change](fam["benefits"][0]) for fam in families]) + income = v["council_tax_reduction_applicable_income"] + with_tax = income + v["tax"] + r = route(v) + passported = v["council_tax_reduction_relevant_income_based_benefit"].astype(bool) + general = (r[base] == 0) & (r[plus] == 0) & ~passported[base] & ~passported[plus] + unfloored = general & (income[base] > 0) + assert np.allclose( + (with_tax[plus] - with_tax[base])[unfloored], added[unfloored], atol=0.01 + ), (change, [f for f, u in zip(families, unfloored) if u]) + ctr = v["council_tax_benefit"] + same_route = r[base] == r[plus] + assert np.all((ctr[plus] <= ctr[base] + 0.01)[same_route]), change + national = np.array([fam["area"] in NATIONAL_SCHEME_AREAS for fam in families]) + liability = v["liability"] + partial = ( + general + & national + & (ctr[base] > 0.01) + & (ctr[plus] > 0.01) + & (ctr[base] < liability[base] - 0.01) + & (ctr[plus] < liability[plus] - 0.01) + ) + assert np.allclose( + (ctr[base] - ctr[plus])[partial], + (WITHDRAWAL_RATE * (income[plus] - income[base]))[partial], + atol=0.01, + ), change + if PARTIAL_AWARD_PENSIONER in families: + assert partial[families.index(PARTIAL_AWARD_PENSIONER)] + + +@st.composite +def war_pensions(draw): + return { + "afcs_reported": draw(money(15_000)), + "war_widows_pension_reported": draw(st.one_of(st.just(0.0), money(8_000))), + } + + +@PROPERTY_SETTINGS +@given(st.data()) +def test_war_pensions_never_count(data): + # Every draw includes the Kingston couple, so a council whose schedules + # keep the £10 is always exercised. + families = data.draw(st.lists(any_family, min_size=1, max_size=6)) + families = families + [KINGSTON_COUPLE] + payments = [[data.draw(war_pensions()) for _ in fam["ages"]] for fam in families] + payments[-1] = [ + {"afcs_reported": 5_000.0, "war_widows_pension_reported": 3_000.0} + ] * 2 + variants = [] + for fam, pay in zip(families, payments): + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, dict(enumerate(pay)))) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + income = v["council_tax_reduction_applicable_income"] + r = route(v) + general = (r[base] == 0) & (r[plus] == 0) + assert general[-1] + rise = income[plus] - income[base] + assert np.allclose(rise[general], 0, atol=0.01), [ + (families[k]["area"], payments[k], rise[k]) + for k in np.flatnonzero(general & ~np.isclose(rise, 0, atol=0.01))[:3] + ] + + +DEPENDANT_CHANGES = { + "iidb": {"iidb_reported": 0.5 * IIDB_FULL}, + "incapacity_benefit": {"incapacity_benefit_reported": LONG_TERM_IB}, + "sda": {"sda_reported": 1.0}, +} + + +@PROPERTY_SETTINGS +@given( + st.lists(any_family, min_size=1, max_size=6), + st.sampled_from(sorted(DEPENDANT_CHANGES)), + st.integers(16, 19), +) +@example([GENERAL_RULES_PENSIONER, KINGSTON_COUPLE], "iidb", 17) +# Found by CI: a dependant of 19 is under 16 years younger than a claimant of +# 32, so without the declared roles the model read them as a partner. +@example([YOUNG_PARENT], "iidb", 19) +def test_dependants_benefits_never_count(families, change, dependant_age): + families = [{**fam, "dependant_age": dependant_age} for fam in families] + variants = [] + for fam in families: + dependant = len(fam["ages"]) + variants.append((fam, 5_000.0, {})) + variants.append((fam, 5_000.0, {dependant: DEPENDANT_CHANGES[change]})) + v = calculate(variants) + base, plus = slice(0, None, 2), slice(1, None, 2) + income = v["council_tax_reduction_applicable_income"] + r = route(v) + general = (r[base] == 0) & (r[plus] == 0) + rise = income[plus] - income[base] + assert np.allclose(rise[general], 0, atol=0.01), [ + (families[k]["area"], families[k]["ages"], change, rise[k]) + for k in np.flatnonzero(general & ~np.isclose(rise, 0, atol=0.01))[:3] + ] diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 21bdd923ec..7bfffd350d 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1927,12 +1927,27 @@ def restatements(draw): stop = start + datetime.timedelta( days=draw(st.integers(0, (ends - start).days - 1)) ) - route = draw(st.sampled_from(sorted(ROUTES))) + # Scenario.parameter_changes types its values as numbers and booleans, so + # a list-valued parameter (such as the Universal Credit capital sources) + # is restated through the other routes only. + routes = sorted(ROUTES) + if not isinstance(value, (int, float, bool)): + routes.remove("parameter_changes") + route = draw(st.sampled_from(routes)) return name, start, stop, value, route @PROPERTY_SETTINGS @given(change=restatements()) +@example( + change=( + "gov.dwp.universal_credit.means_test.capital.person_sources", + datetime.date(2013, 4, 29), + datetime.date(2013, 4, 29), + [], + "reform_dict", + ) +) def test_restating_a_parameters_own_value_changes_nothing(change): name, start, stop, value, route = change simulation = ROUTES[route](as_dict(name, start, stop, value)) diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 079089376a..1becfb4bc7 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -313,17 +313,31 @@ def test_the_council_tax_reduction_passport(reformed, year): @pytest.mark.parametrize("year", YEARS) def test_no_family_is_moved_off_its_legacy_benefits(reformed, unreformed, year): - # Income-based JSA and Income Support close from April 2026, and the - # model then moves a working-age family reporting either, unless it is on - # Pension Credit, off all its legacy benefits (legacy_benefits_closed), so - # their awards end. Every family here reports one of them. The reform pays every reported award in every - # year, so it holds the move off; otherwise an adult outside the couple - # would stop being on the ESA the reform still pays them, and on its JSA - # while income-based JSA is open (test_each_person_is_on_their_own_award). + # The annual closure proxy follows each award's owners (SI 2014/1230 + # regs 44 and 46). Only the excluded adult reports a closing benefit in + # excluded_adult_no_award and excluded_income_support, so their reports + # never close the claimant's award. The other claimants report Income + # Support, which closes from 2026 under the existing model parameters. moved = year > DATASET_YEAR - assert by_family(unreformed, "legacy_benefits_closed", year) == dict.fromkeys( - NAMES, moved - ) + assert by_family(unreformed, "legacy_benefits_closed", year) == { + "excluded_adult": moved, + "excluded_adult_no_award": False, + "own_esa": moved, + "own_jsa": moved, + "own_esa_over_tariff": moved, + "excluded_income_support": False, + } + assert by_person(unreformed, "person_legacy_benefits_closed", year) == { + "excluded_adult_0": moved, + "excluded_adult_1": moved, + "excluded_adult_no_award_0": False, + "excluded_adult_no_award_1": moved, + "own_esa_0": moved, + "own_jsa_0": moved, + "own_esa_over_tariff_0": moved, + "excluded_income_support_0": False, + "excluded_income_support_1": moved, + } assert by_family(reformed, "legacy_benefits_closed", year) == dict.fromkeys( NAMES, False ) @@ -792,3 +806,13 @@ def member(age, claimant_or_partner_in_first_year, **reported): == claimant_or_partner_esa ) assert simulation.calculate("income_support", year)[0] == income_support + + +@pytest.mark.parametrize("year", range(DATASET_YEAR, DATASET_YEAR + YEARS_IN_FUTURE)) +def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): + # The reform pays reported awards for all ten years, including an adult's + # own award outside the couple. Both award owners' closure controls must + # remain false; holding only the claimant's closure leaves that adult's + # reported ESA in household income while incorrectly ending their status. + closed = by_person(reformed, "person_legacy_benefits_closed", year) + assert closed == dict.fromkeys(closed, False) diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 2ee5dfa4b5..34d7bb182c 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -342,7 +342,17 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): 3307.25, 31407.05078125, ], - "capital_gains_tax": [7663.7998046875, 8880, 8880, 8880, 8263.7998046875, 8880] + # The last row's 37,000 of gains is split between the higher band + # left on top of its 88,930 taxable income and the band above it, at + # equal rates; float32 rounds the two parts' sum a thousandth low. + "capital_gains_tax": [ + 7663.7998046875, + 8880, + 8880, + 8880, + 8263.7998046875, + 8879.9990234375, + ] * 2, } for variable, values in expected.items(): diff --git a/policyengine_uk/tests/test_labour_supply_couples.py b/policyengine_uk/tests/test_labour_supply_couples.py index d93937bffa..4b51afa30b 100644 --- a/policyengine_uk/tests/test_labour_supply_couples.py +++ b/policyengine_uk/tests/test_labour_supply_couples.py @@ -204,7 +204,7 @@ def test_cohabiting_participation_reaches_employment_income(monkeypatch): monkeypatch.setattr( participation, "impute_wages_for_nonworkers", lambda *args: np.zeros(3) ) - monkeypatch.setattr(np.random, "random", lambda: 0.02) + # Seed1751 gives local MT19937 draws0.018622/0.108093, below both exit thresholds. outcomes = [] for married in [True, False]: sim = Microsimulation( @@ -230,7 +230,9 @@ def controlled_gain_to_work(current, *args, **kwargs): monkeypatch.setattr( participation, "calculate_gain_to_work", controlled_gain_to_work ) - response = participation.apply_participation_responses(sim, YEAR) + response = participation.apply_participation_responses( + sim, YEAR, random_seed=1751 + ) np.testing.assert_allclose( response["direct_participation_change"], [-0.034, -0.11325] ) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index ef44e02ecc..7850e3a79a 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -61,9 +61,8 @@ (esa_income_eligible and jsa_income_eligible, read independently from the law in legacy_award_work_reference). The screens also end the award once the family's legacy benefits have closed (legacy_benefits_closed, #2119), - and the model then ends every member's status too; no family here reaches - that in 2025, when Income Support and income-based JSA are open and - nobody reports tax credits; + for their own award only; no family here reaches that in 2025, when + Income Support and income-based JSA are open and nobody reports tax credits; - a person is on an award exactly when they are the payee of their couple's positive award (the claimant or partner who reports it, or the claimant) or, for anyone else, when their own claim passes: they are not engaged in diff --git a/policyengine_uk/tests/test_legacy_award_work_properties.py b/policyengine_uk/tests/test_legacy_award_work_properties.py index 2e2ecc3f14..1779a07305 100644 --- a/policyengine_uk/tests/test_legacy_award_work_properties.py +++ b/policyengine_uk/tests/test_legacy_award_work_properties.py @@ -23,10 +23,9 @@ JSA regulations have no exception for carers doing unrelated paid work); - when the claimant or partner reports the award, adding a member outside the family (a non-dependent adult, with any award and any work) never - changes either screen's work and capital tests. It can change a screen - only through the closure, which the model reads from every member's - reports: the screen with them is the screen without them, while their - report has not closed the family's legacy benefits. + changes an open couple claim's screen or award. If the couple's own + reports have closed, an outside member's separate open claim may pass + the aggregate screen without reopening the couple's award. Adults are employees (pay within the basic rate band), self-employed (a profit within the Class 4 upper limit) or employees with a self-employment @@ -318,11 +317,20 @@ def test_member_outside_the_family_never_changes_the_couples_screen(units, year, for i, (couple, *_rest) in enumerate(without): assert not flags[ends[n + i] - 1] if any(a[reported] > 0 for a in couple): - # The closure reads every member's reports (#2119). - closes = legacy_benefits_closed(couple, [extras[i]], parameters) - if closes and not legacy_benefits_closed(couple, [], parameters): - event("an outside member's report closes the family's") - assert eligible[n + i] == (eligible[i] and not closes), ( + closed = legacy_benefits_closed(couple, [], parameters) + # An open couple report still selects the same work screen. + # Once closed, only an outside member's separate claim can + # be a candidate, so the aggregate screen may then change. + if not closed: + assert eligible[n + i] == eligible[i], ( + variable, + units[i], + extras[i], + ) + couple_award = sim.calculate( + "claimant_or_partner_" + reported.removesuffix("_reported"), year + ) + assert couple_award[n + i] == couple_award[i], ( variable, units[i], extras[i], diff --git a/policyengine_uk/tests/test_legacy_closure_scope.py b/policyengine_uk/tests/test_legacy_closure_scope.py new file mode 100644 index 0000000000..4126a21ddc --- /dev/null +++ b/policyengine_uk/tests/test_legacy_closure_scope.py @@ -0,0 +1,190 @@ +"""Ownership of the model's annual legacy-closure proxy (#2395). + +These fixtures keep the existing annual dates and eligibility screens. They +do not represent actual migration notices, deadlines, or statutory run-ons. +The separate adult's award is conditional on their own zero-capital, +non-working claim; sharing a benefit unit does not make it the couple's. +""" + +import pytest + +from policyengine_uk import Simulation + + +def household(year, claimant=None, partner=None, outside=None, child=False): + members = { + "claimant": {"age": 30, "is_claimant_or_partner": True}, + "partner": {"age": 30, "is_claimant_or_partner": True}, + "outside": { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + }, + } + for name, changes in ( + ("claimant", claimant), + ("partner", partner), + ("outside", outside), + ): + members[name].update(changes or {}) + if child: + members["claimant"]["is_parent"] = True + members["partner"]["is_parent"] = True + members["child"] = {"age": 5, "is_claimant_or_partner": False} + return { + "people": { + name: {key: {str(year): value} for key, value in inputs.items()} + for name, inputs in members.items() + }, + "benunits": { + "unit": { + "members": list(members), + "esa_income_assessable_capital": {str(year): 0}, + "jsa_income_assessable_capital": {str(year): 0}, + } + }, + "households": {"household": {"members": list(members)}}, + } + + +@pytest.mark.parametrize("report", ["income_support_reported", "jsa_income_reported"]) +@pytest.mark.parametrize("payee", ["claimant", "partner"]) +def test_outside_closed_report_does_not_close_couple_award(report, payee): + inputs = {payee: {"esa_income_reported": 5_000}} + sim = Simulation(situation=household(2027, **inputs, outside={report: 1_000})) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 5_000 + assert sim.calculate("esa_income", 2027)[0] == 5_000 + + +def test_outside_pension_credit_report_does_not_shield_couple_closure(): + sim = Simulation( + situation=household( + 2027, + claimant={"income_support_reported": 1_000, "esa_income_reported": 5_000}, + outside={"age": 70, "pension_credit_reported": 1_000}, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 0 + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +def test_couple_closure_does_not_end_outside_own_award(award): + # Tax credits are closed in 2025, while income-based JSA is still active. + sim = Simulation( + situation=household( + 2025, + claimant={"child_tax_credit_reported": 2_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2025)[0] + assert sim.calculate(award, 2025)[0] == 5_000 + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == 0 + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert sim.calculate(status, 2025).tolist() == [False, False, True, False] + + +def test_outside_own_closed_report_still_ends_only_their_own_claim(): + sim = Simulation( + situation=household( + 2027, + claimant={"esa_income_reported": 5_000}, + outside={"esa_income_reported": 5_000, "income_support_reported": 1_000}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("esa_income", 2027)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2027).tolist() == [ + True, + False, + False, + ] + + +def test_preclosure_report_does_not_end_outside_own_award(): + sim = Simulation( + situation=household( + 2024, + claimant={"child_tax_credit_reported": 2_000}, + outside={"esa_income_reported": 5_000}, + child=True, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2024)[0] + assert sim.calculate("esa_income", 2024)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2024).tolist() == [ + False, + False, + True, + False, + ] + + +def test_existing_pension_age_exception_is_preserved(): + sim = Simulation( + situation=household( + 2027, + claimant={"age": 70, "income_support_reported": 1_000}, + partner={"age": 70}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +@pytest.mark.parametrize( + "mode", ["formula", "raw_total", "distinct_entered", "nil", "neutralized"] +) +def test_closed_reports_preserve_stored_award_value_contract(award, mode): + inputs = household( + 2025, + claimant={"child_tax_credit_reported": 2_000, f"{award}_reported": 5_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + if mode in {"raw_total", "distinct_entered", "nil"}: + entered = {"raw_total": 10_000, "distinct_entered": 7_000, "nil": 0}[mode] + inputs["benunits"]["unit"][award] = {"2025": entered} + sim = Simulation(situation=inputs) + if mode == "neutralized": + sim.tax_benefit_system.neutralize_variable(award) + # Raw report totals retain the held-award path; distinct direct amounts + # remain the couple's. Formula awards instead omit only closed reports. + aggregate, scoped = { + "formula": (5_000, 0), + "raw_total": (10_000, 5_000), + "distinct_entered": (7_000, 7_000), + "nil": (0, 0), + "neutralized": (0, 0), + }[mode] + assert sim.calculate(award, 2025)[0] == aggregate + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == scoped + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert bool(sim.calculate(status, 2025)[2]) == (mode not in {"nil", "neutralized"}) + + +def test_outside_own_pension_credit_age_input_is_used(): + sim = Simulation( + situation=household( + 2027, + outside={ + "income_support_reported": 1_000, + "has_attained_state_pension_credit_qualifying_age": True, + "is_SP_age": False, + }, + ) + ) + # An explicit qualifying-age input tests the interface mapping, not a + # new assertion about the legal timetable or actual migration notices. + assert not sim.calculate("person_legacy_benefits_closed", 2027)[2] diff --git a/policyengine_uk/tests/test_lha_freeze.py b/policyengine_uk/tests/test_lha_freeze.py index d124cb3704..e7833c39e7 100644 --- a/policyengine_uk/tests/test_lha_freeze.py +++ b/policyengine_uk/tests/test_lha_freeze.py @@ -145,3 +145,66 @@ def test_frozen_years_hold_the_reset_level_for_an_uncapped_area(): assert _weekly_rate(frozen_year, "MAIDSTONE", "C") == pytest.approx( reset, abs=0.01 ) + + +# Northern Ireland: the Rent Officers Orders behind gov.dwp.LHA cover Great +# Britain only. The Northern Ireland Housing Executive determines Northern +# Ireland's rates under Northern Ireland regulations (gov.dfc.LHA). + +NI_LHA_VARIABLES = ("housing_benefit_LHA_rate", "LHA_cap", "uc_LHA_cap") + + +def _renter(year: int, region: str) -> dict: + return { + "people": {"person": {"age": {year: 30}}}, + "benunits": { + "benunit": { + "members": ["person"], + "LHA_category": {year: "C"}, + "housing_benefit_LHA_category": {year: "C"}, + "benunit_rent": {year: 100_000}, + } + }, + "households": { + "household": { + "members": ["person"], + "region": {year: region}, + "tenure_type": {year: "RENT_PRIVATELY"}, + "rent": {year: 100_000}, + } + }, + } + + +def _lha_values(region: str, reform=None, year: int = 2026) -> dict: + simulation = Simulation(situation=_renter(year, region), reform=reform) + return { + variable: float(simulation.calculate(variable, year)[0]) + for variable in NI_LHA_VARIABLES + } + + +def test_great_britain_lha_reforms_leave_northern_ireland_rates_unchanged(): + for reform in ( + {"gov.dwp.LHA.freeze": {"2026": False}}, + {"gov.dwp.LHA.percentile": {"2026": 0.5}}, + ): + assert _lha_values("NORTHERN_IRELAND", reform) == _lha_values( + "NORTHERN_IRELAND" + ) + + +def test_great_britain_lha_unfreeze_changes_great_britain_rates(): + baseline = _lha_values("LONDON") + unfrozen = _lha_values("LONDON", {"gov.dwp.LHA.freeze": {"2026": False}}) + for variable in NI_LHA_VARIABLES: + assert unfrozen[variable] != baseline[variable], variable + + +def test_northern_ireland_lha_unfreeze_changes_only_northern_ireland_rates(): + reform = {"gov.dfc.LHA.freeze": {"2026": False}} + baseline = _lha_values("NORTHERN_IRELAND") + unfrozen = _lha_values("NORTHERN_IRELAND", reform) + for variable in NI_LHA_VARIABLES: + assert unfrozen[variable] != baseline[variable], variable + assert _lha_values("LONDON", reform) == _lha_values("LONDON") diff --git a/policyengine_uk/tests/test_lha_published_rates.py b/policyengine_uk/tests/test_lha_published_rates.py index c046f7e476..2de404f609 100644 --- a/policyengine_uk/tests/test_lha_published_rates.py +++ b/policyengine_uk/tests/test_lha_published_rates.py @@ -240,12 +240,19 @@ def test_statutory_percentile_positions(): def reformed_parameters( freeze: dict, percentile: float, maximum_scale: float, minimum: bool ): - """Copies of the LHA parameters with reforms applied by year.""" + """Copies of the LHA parameters with reforms applied by year. + + The freeze and percentile reforms apply in Great Britain and, through + gov.dfc.LHA, in Northern Ireland alike. + """ lha = SYSTEM.parameters.gov.dwp.LHA.clone() + ni_lha = SYSTEM.parameters.gov.dfc.LHA.clone() for year, frozen in freeze.items(): - lha.freeze.update(period=str(year), value=frozen) + for node in (lha, ni_lha): + node.freeze.update(period=str(year), value=frozen) for year in range(2020, 2041): - lha.percentile.update(period=str(year), value=percentile) + for node in (lha, ni_lha): + node.percentile.update(period=str(year), value=percentile) lha.march_2020_minimum.update(period=str(year), value=minimum) for node in (lha.maximum, lha.maximum_monthly): for category in CATEGORIES: @@ -257,6 +264,7 @@ def reformed_parameters( return SimpleNamespace( gov=SimpleNamespace( dwp=SimpleNamespace(LHA=lha), + dfc=SimpleNamespace(LHA=ni_lha), indices=SYSTEM.parameters.gov.indices, ) ) diff --git a/policyengine_uk/tests/test_ni_full_year_director_2022.py b/policyengine_uk/tests/test_ni_full_year_director_2022.py new file mode 100644 index 0000000000..d54b2bd78f --- /dev/null +++ b/policyengine_uk/tests/test_ni_full_year_director_2022.py @@ -0,0 +1,273 @@ +"""The declared 2022–23 annual employee schedule, not interim director payroll.""" + +from pathlib import Path + +import numpy as np +import pytest +from policyengine_core.parameters import load_parameter_file + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.utils.scenario import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +DIRECTORS = "gov.hmrc.national_insurance.class_1.directors.full_year_category_a_2022_23" +DIRECTORY = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/national_insurance/class_1/directors/full_year_category_a_2022_23" +) +COMPONENTS = ( + "ni_class_1_employee_primary", + "ni_class_1_employee_additional", + "ni_class_1_employee", +) + + +def situation(year=2022, flags=(True,), earnings=(30000,), age=40): + people = {} + for i, earnings_i in enumerate(earnings): + people[f"p{i}"] = { + "age": {year: age}, + "employment_income": {year: earnings_i}, + } + if flags[i] is not None: + people[f"p{i}"]["is_full_year_category_a_director"] = {year: flags[i]} + return { + "people": people, + "benunits": {f"b{i}": {"members": [p]} for i, p in enumerate(people)}, + "households": {"h": {"members": list(people)}}, + } + + +def test_raw_coverage_and_scale_metadata(): + coverage = load_parameter_file(str(DIRECTORY / "in_effect.yaml"), "in_effect") + for date, expected in [ + ("2022-04-05", False), + ("2022-04-06", True), + ("2023-04-05", True), + ("2023-04-06", False), + ]: + assert coverage(date) == expected + scale = load_parameter_file( + str(DIRECTORY / "employee_scale.yaml"), "employee_scale" + ) + assert scale.metadata["threshold_unit"] == "currency-GBP" + assert scale.metadata["rate_unit"] == "/1" + assert scale.metadata["reference"] + assert not scale.metadata.get("uprating") + for bracket, threshold, rate in zip( + scale.brackets, [0, 11908, 50270], [0, 0.1273, 0.0273] + ): + assert bracket.threshold("2022-04-06") == threshold + assert bracket.rate("2022-04-06") == rate + for leaf in (bracket.threshold, bracket.rate): + assert [v.instant_str for v in leaf.values_list] == ["2022-04-06"] + assert not leaf.metadata.get("uprating") + + +def test_processed_gate_and_input_metadata(): + system = CountryTaxBenefitSystem() + for year, active in [(2021, False), (2022, True), (2023, False), (2030, False)]: + node = system.parameters( + str(year) + ).gov.hmrc.national_insurance.class_1.directors + assert node.full_year_category_a_2022_23.in_effect == active + scale = system.parameters("2022").gov.hmrc.national_insurance.class_1.directors + assert scale.full_year_category_a_2022_23.employee_scale.thresholds == [ + 0, + 11908, + 50270, + ] + assert scale.full_year_category_a_2022_23.employee_scale.rates == [ + 0, + 0.1273, + 0.0273, + ] + variable = system.variables["is_full_year_category_a_director"] + assert variable.default_value is False + assert variable.value_type is bool + assert "2022–23 only" in variable.label + + +@pytest.mark.parametrize("year", [2021, 2022, 2023, 2026]) +def test_default_false_and_out_of_year_true_preserve_ordinary(year): + baseline = Simulation(situation=situation(year, (None,))) + false = Simulation(situation=situation(year, (False,))) + true = Simulation(situation=situation(year, (True,))) + for variable in (*COMPONENTS, "ni_class_1_employer"): + expected = baseline.calculate(variable, year) + np.testing.assert_array_equal(false.calculate(variable, year), expected) + if year != 2022 or variable == "ni_class_1_employer": + np.testing.assert_array_equal(true.calculate(variable, year), expected) + + +def test_monthly_allocation_components_person_mask_and_query_order(): + data = situation(flags=(True, True, False), earnings=(30000, 60000, 30000)) + first = Simulation(situation=data) + second = Simulation(situation=data) + expected = [2303.1116, 5149.1116, 2665.9] + # Query the final total first, then reverse the component query order. + np.testing.assert_allclose( + first.calculate(COMPONENTS[2], 2022), expected, atol=0.01 + ) + additional = second.calculate(COMPONENTS[1], 2022) + primary = second.calculate(COMPONENTS[0], 2022) + np.testing.assert_allclose(primary + additional, expected, atol=0.01) + np.testing.assert_allclose( + second.calculate(COMPONENTS[2], 2022), expected, atol=0.01 + ) + for variable in COMPONENTS: + months = [ + first.calculate(variable, f"2022-{month:02d}") for month in range(1, 13) + ] + np.testing.assert_allclose( + sum(months), first.calculate(variable, 2022), atol=0.01 + ) + # Equal allocations follow annual inputs; they are not observed payroll cash. + for month in months[1:]: + np.testing.assert_array_equal(month, months[0]) + + +def test_previous_year_fact_does_not_extend_implementation_coverage(): + data = situation() + data["people"]["p0"]["age"][2023] = 41 + data["people"]["p0"]["employment_income"][2023] = 30000 + sim = Simulation(situation=data) + baseline = Simulation(situation=situation(2023, (None,))) + np.testing.assert_array_equal( + sim.calculate("ni_class_1_employee", 2023), + baseline.calculate("ni_class_1_employee", 2023), + ) + + +@pytest.mark.parametrize("age", [15, 80]) +def test_existing_final_age_gate_remains_authoritative(age): + # Inconsistent declared status deliberately tests composition, not entitlement. + sim = Simulation(situation=situation(age=age)) + assert sim.calculate("ni_class_1_employee", 2022)[0] == 0 + + +def test_ni_earnings_composition_is_not_replaced_with_salary(): + data = situation(earnings=(28000,)) + data["people"]["p0"].update( + statutory_sick_pay={2022: 2000}, + employee_pension_contributions_reported={2022: 1000}, + ) + sim = Simulation(situation=data) + assert sim.calculate("ni_class_1_income", 2022)[0] == 30000 + assert sim.calculate("ni_class_1_employee", 2022)[0] == pytest.approx( + 2303.1116, abs=0.01 + ) + + +def test_explicit_final_input_remains_authoritative(): + data = situation() + data["people"]["p0"]["ni_class_1_employee"] = {2022: 123} + sim = Simulation(situation=data) + assert sim.calculate("ni_class_1_employee", 2022)[0] == pytest.approx(123, abs=0.01) + + +def test_director_scale_reform_changes_only_the_declared_route(): + data = situation(flags=(True, False), earnings=(30000, 30000)) + + def reform_schedule(simulation): + system = simulation.tax_benefit_system + system.reset_parameters() + scale = system.parameters.gov.hmrc.national_insurance.class_1.directors + scale.full_year_category_a_2022_23.employee_scale.brackets[1].rate.update( + period="year:2022-01-01:1", value=0.2 + ) + system.process_parameters() + + sim = Simulation( + situation=data, + scenario=Scenario( + simulation_modifier=reform_schedule, applied_before_data_load=True + ), + ) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employee", 2022), [3618.4, 2665.9], atol=0.01 + ) + + +def test_natural_cash_support_is_unchanged_by_director_status(): + # This is the existing model's calendar-2022 support, not a new rate rule. + values = [] + other_taxes = [] + for flag, ni in [(False, 2665.9), (True, 2303.1116)]: + data = situation(flags=(flag,)) + data["benunits"]["b0"]["would_claim_uc"] = {2022: False} + data["households"]["h"]["region"] = {2022: "LONDON"} + data["households"]["h"]["household_owns_tv"] = {2022: False} + sim = Simulation(situation=data) + assert sim.calculate("ebr_council_tax_rebate", 2022)[0] == 150 + assert sim.calculate("ebr_energy_bills_credit", 2022)[0] == 199 + assert sim.calculate("household_benefits", 2022)[0] == 349 + assert sim.calculate("tv_licence", 2022)[0] == 0 + assert sim.calculate("income_tax", 2022)[0] == 3486 + assert sim.calculate("national_insurance", 2022)[0] == pytest.approx( + ni, abs=0.01 + ) + actual_cash = sim.calculate("household_net_income", 2022)[0] + other_taxes.append(float(sim.calculate("household_tax", 2022)[0]) - ni) + values.append(float(actual_cash)) + # Only employee NI changes: the literal cash gain equals the NI reduction. + # Tolerance reflects float32 household totals, without a payroll rounding rule. + assert values[1] - values[0] == pytest.approx(362.7884, abs=0.01) + assert other_taxes[0] == pytest.approx(other_taxes[1], abs=0.01) + + +def test_director_primary_propagates_through_existing_class_4_cap(): + """Composition of existing rules, not a new standalone Class 4 law oracle.""" + results = [] + # Profit is net of £2,000 expenses: gross receipts exceed profit by more + # than the trading allowance. No loss, capital allowance or output is supplied. + for flag, primary, cap, total_ni in [ + (False, 2665.9, 2082.6041179856, 4912.3041179856), + (True, 2303.1116, 2343.6029669065, 4810.5145669065), + ]: + data = situation(flags=(flag,)) + data["people"]["p0"].update( + self_employment_income={2022: 60000}, + self_employment_gross_receipts={2022: 62000}, + ) + data["benunits"]["b0"]["would_claim_uc"] = {2022: False} + data["households"]["h"].update( + region={2022: "LONDON"}, household_owns_tv={2022: False} + ) + sim = Simulation(situation=data) + assert sim.calculate("trading_allowance_deduction", 2022)[0] == 0 + assert sim.calculate("ni_class_4_profits", 2022)[0] == 60000 + assert sim.calculate("ni_class_1_employee_primary", 2022)[0] == pytest.approx( + primary, abs=0.01 + ) + assert sim.calculate("ni_class_1_employee_additional", 2022)[0] == 0 + assert sim.calculate("ni_class_2", 2022)[0] == pytest.approx(163.8, abs=0.01) + # Existing 2022 parameters: UPL50270, LPL11908, Class4rates.0973/.0273, + # Class2weekly3.15. AboveUPL, case1false. Headroom =38362*.0973 + # +53*3.15 -52*3.15 -primary. Remaining main band is charged at.0273. + assert sim.calculate("ni_class_4_maximum", 2022)[0] == pytest.approx( + cap, abs=0.01 + ) + assert cap < 3998.2516 # Uncapped Class 4:38362*.0973+9730*.0273. + assert sim.calculate("ni_class_4", 2022)[0] == pytest.approx(cap, abs=0.01) + assert sim.calculate("national_insurance", 2022)[0] == pytest.approx( + total_ni, abs=0.01 + ) + results.append( + { + variable: float(sim.calculate(variable, 2022)[0]) + for variable in [ + "household_net_income", + "household_benefits", + "income_tax", + "ni_class_1_employer", + ] + } + ) + for variable in ["household_benefits", "income_tax", "ni_class_1_employer"]: + assert results[0][variable] == pytest.approx(results[1][variable], abs=0.01) + # Class 1 falls362.7884, Class4rises260.9988489; final cash gain is101.7895511. + assert results[1]["household_net_income"] - results[0][ + "household_net_income" + ] == pytest.approx(101.7895511, abs=0.01) diff --git a/policyengine_uk/tests/test_ni_health_and_social_care_levy.py b/policyengine_uk/tests/test_ni_health_and_social_care_levy.py index d0cbd4d53f..e240128b47 100644 --- a/policyengine_uk/tests/test_ni_health_and_social_care_levy.py +++ b/policyengine_uk/tests/test_ni_health_and_social_care_levy.py @@ -53,10 +53,9 @@ / "hmrc" / "national_insurance" / "class_1" - / "rates" ) -EMPLOYER_FILE = RATES / "employer.yaml" -EMPLOYEE_MAIN_FILE = RATES / "employee" / "main.yaml" +EMPLOYER_FILE = RATES / "employer_scale.yaml" +EMPLOYEE_MAIN_FILE = RATES / "employee_scale.yaml" LEVY_START = datetime.date(2022, 4, 6) # HSCL Act 2021 s.5(1): tax year 2022-23 LEVY_END = datetime.date(2022, 11, 6) # Repeal Act 2022 s.2(1) @@ -72,12 +71,12 @@ @pytest.fixture(scope="module") def employer(): - return load_parameter_file(str(EMPLOYER_FILE), "employer") + return load_parameter_file(str(EMPLOYER_FILE), "employer").brackets[1].rate @pytest.fixture(scope="module") def employee_main(): - return load_parameter_file(str(EMPLOYEE_MAIN_FILE), "main") + return load_parameter_file(str(EMPLOYEE_MAIN_FILE), "main").brackets[1].rate @pytest.fixture(scope="module") diff --git a/policyengine_uk/tests/test_ni_metadata_compat.py b/policyengine_uk/tests/test_ni_metadata_compat.py new file mode 100644 index 0000000000..4d0f27f1d8 --- /dev/null +++ b/policyengine_uk/tests/test_ni_metadata_compat.py @@ -0,0 +1,141 @@ +"""Pinned source-extracted API contracts; no installed API or service calls.""" + +from types import SimpleNamespace +from pathlib import Path +from policyengine_core.parameters.operations.get_parameter import get_parameter +import json +import pytest + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.tests.fixtures.ni_metadata_api_contract import ( + build_parameters, + _normalize_reform_values, + _apply_reform_to_system, + create_policy_reform, + get_safe_json, +) + +OLD = "gov.hmrc.national_insurance.class_1.rates.employee.main" +NEW = "gov.hmrc.national_insurance.class_1.employee_scale[1].rate" + + +@pytest.mark.parametrize("path", [OLD, NEW]) +def test_actual_core_get_parameter_api_normalization_and_update(path, record_property): + original = CountryTaxBenefitSystem() + system = original.clone() + data = {path: {"2026-01-01.2026-12-31": "0.16"}} + normalized = _normalize_reform_values(SimpleNamespace(), data, system) + assert normalized[path]["2026-01-01.2026-12-31"] == 0.16 + build_parameters(SimpleNamespace(tax_benefit_system=system)) + _apply_reform_to_system(SimpleNamespace(), system, normalized) + actual = build_parameters(SimpleNamespace(tax_benefit_system=system)) + # Exact immutable-B control reproduces baseline-name overwrite in this + # synthetic reserialization: construction-cached Country metadata is not + # a live changed-system export. Preserve that whole old record, rather + # than introducing an unrelated baseline/API lifecycle change. + # Base-control result SHA25645bc659d1915a86afb6340ef276e535588783f88a46218b993f4cb3c97a14c02. + expected = json.loads( + ( + Path(__file__).parent / "fixtures/ni_legacy_parameter_metadata.json" + ).read_text() + )["parameters"][OLD] + assert actual[OLD] == {**expected, "household": False, "economy": False} + assert actual[NEW] == {**expected, "parameter": NEW} + alias = get_parameter(system.parameters, OLD) + canonical = get_parameter(system.parameters, NEW) + assert alias.metadata is canonical.metadata + assert alias.values_list is canonical.values_list + assert alias("2026") == canonical("2026") == 0.16 + assert any( + entry.instant_str == "2026-01-01" and entry.value == 0.16 + for entry in alias.values_list + ) + assert system.parameters.baseline.get_child(OLD)("2026") == 0.08 + record_property( + "source_extracted_metadata_contract", + json.dumps( + { + "update_path": path, + "old_complete_record": actual[OLD], + "canonical_complete_record": actual[NEW], + "live_root_value": alias("2026"), + "baseline_value": system.parameters.baseline.get_child(OLD)("2026"), + "same_values_list": alias.values_list is canonical.values_list, + "same_metadata": alias.metadata is canonical.metadata, + }, + sort_keys=True, + ), + ) + assert original.parameters.get_child(OLD)("2026") == 0.08 + json.dumps(actual, allow_nan=False) + + +@pytest.mark.parametrize("path", [OLD, NEW]) +def test_actual_legacy_api_manual_children_reform_route(path): + original = CountryTaxBenefitSystem() + reform = create_policy_reform({path: {"2026-01-01.2026-12-31": 0.14}}) + updated = reform(original) + assert updated.parameters.get_child(OLD)("2026") == 0.14 + assert updated.parameters.get_child(NEW)("2026") == 0.14 + assert original.parameters.get_child(OLD)("2026") == 0.08 + + +def test_exact_pinned_json_helper_handles_nonfinite_nested_values(): + assert get_safe_json({"value": [float("inf"), float("-inf"), None, 0.08]}) == { + "value": ["Infinity", "-Infinity", None, 0.08] + } + + +# Only these discovery flags intentionally differ from the frozen H record. +HIDDEN_DISCOVERY_PATHS = ( + "gov.hmrc.national_insurance.class_1.rates.employee.main", + "gov.hmrc.national_insurance.class_1.rates.employee.additional", + "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", + "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", + "gov.hmrc.national_insurance.class_1.rates.employer", + "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", + "gov.hmrc.national_insurance.class_4.rates.main", + "gov.hmrc.national_insurance.class_4.rates.additional", + "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", + "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", + "gov.hmrc.national_insurance.class_1.employee_scale[0].threshold", + "gov.hmrc.national_insurance.class_1.employee_scale[0].rate", + "gov.hmrc.national_insurance.class_1.employer_scale[0].threshold", + "gov.hmrc.national_insurance.class_1.employer_scale[0].rate", + "gov.hmrc.national_insurance.class_4.contribution_scale[0].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[0].rate", +) + + +@pytest.mark.parametrize("path", HIDDEN_DISCOVERY_PATHS) +def test_fixed_zero_and_legacy_discovery_controls_are_hidden(path): + actual = build_parameters( + SimpleNamespace(tax_benefit_system=CountryTaxBenefitSystem()) + ) + assert actual[path]["household"] is False + assert actual[path]["economy"] is False + + +def test_discovery_views_do_not_hide_or_mutate_canonical_controls(): + from policyengine_uk.utils.ni_parameter_scales import NI_LEGACY_PATHS + + system = CountryTaxBenefitSystem() + records = build_parameters(SimpleNamespace(tax_benefit_system=system)) + # Scope identity to the current gov tree, excluding the named baseline copy. + discovered = {item.name: item for item in system.parameters.gov.get_descendants()} + for old, new in NI_LEGACY_PATHS.items(): + alias = get_parameter(system.parameters, old) + canonical = get_parameter(system.parameters, new) + assert alias.metadata is canonical.metadata + assert alias.values_list is canonical.values_list + assert records[new]["household"] is True + assert records[new]["economy"] is True + assert records[old] == { + **records[new], + "parameter": old, + "household": False, + "economy": False, + } + assert discovered[old] is not alias + assert discovered[old].metadata is not canonical.metadata + assert discovered[new] is canonical diff --git a/policyengine_uk/tests/test_ni_parameter_scales.py b/policyengine_uk/tests/test_ni_parameter_scales.py new file mode 100644 index 0000000000..de913b6d12 --- /dev/null +++ b/policyengine_uk/tests/test_ni_parameter_scales.py @@ -0,0 +1,558 @@ +"""Public NI scale and legacy leaf contracts, specified before migration.""" + +import json +from pathlib import Path + +import pytest +from policyengine_core.parameters import Parameter, ParameterScale + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.tests.fixtures.ni_metadata_api_contract import build_parameters +from types import SimpleNamespace + +PREFIX = "gov.hmrc.national_insurance." +LEAVES = { + "class_1.rates.employee.main": "class_1.employee_scale[1].rate", + "class_1.rates.employee.additional": "class_1.employee_scale[2].rate", + "class_1.thresholds.primary_threshold": "class_1.employee_scale[1].threshold", + "class_1.thresholds.upper_earnings_limit": "class_1.employee_scale[2].threshold", + "class_1.rates.employer": "class_1.employer_scale[1].rate", + "class_1.thresholds.secondary_threshold": "class_1.employer_scale[1].threshold", + "class_4.rates.main": "class_4.contribution_scale[1].rate", + "class_4.rates.additional": "class_4.contribution_scale[2].rate", + "class_4.thresholds.lower_profits_limit": "class_4.contribution_scale[1].threshold", + "class_4.thresholds.upper_profits_limit": "class_4.contribution_scale[2].threshold", +} + + +def test_canonical_marginal_schedules_are_discoverable(): + parameters = CountryTaxBenefitSystem().parameters + for path in [ + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", + ]: + assert isinstance(parameters.get_child(PREFIX + path), ParameterScale) + + +@pytest.mark.parametrize("legacy,canonical", LEAVES.items()) +def test_ten_legacy_scalar_paths_resolve_to_canonical_leaves(legacy, canonical): + parameters = CountryTaxBenefitSystem().parameters + assert parameters.get_child(PREFIX + legacy) is parameters.get_child( + PREFIX + canonical + ) + + +def test_legacy_callable_attribute_updates_reach_canonical_leaves(): + system = CountryTaxBenefitSystem() + tree = system.parameters.clone() + tree("2026") # Warm canonical and sibling parent reads. + old_parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + old_parent("2026") + old_parent.main.update(period="2026", value=0.19) + assert tree.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") == 0.19 + assert tree("2026").gov.hmrc.national_insurance.class_1.rates.employee.main == 0.19 + assert old_parent("2026").main == 0.19 + assert ( + system.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + + +def test_live_parameter_metadata_preserves_legacy_scalar_records(): + expected = json.loads( + ( + Path(__file__).parent / "fixtures/ni_legacy_parameter_metadata.json" + ).read_text() + )["parameters"] + actual = build_parameters( + SimpleNamespace(tax_benefit_system=CountryTaxBenefitSystem()) + ) + assert {name: actual[name] for name in expected} == { + name: {**record, "household": False, "economy": False} + for name, record in expected.items() + } + json.dumps(actual, allow_nan=False) + + +def test_clone_and_baseline_views_resolve_within_their_own_tree(): + source = CountryTaxBenefitSystem().parameters + clone = source.clone() + source_value = source.gov.hmrc.national_insurance.class_1.rates.employee.main( + "2026" + ) + clone.gov.hmrc.national_insurance.class_1.rates.employee.main.update( + period="2026", value=0.17 + ) + assert clone.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") == 0.17 + assert ( + source.gov.hmrc.national_insurance.class_1.rates.employee.main("2026") + == source_value + ) + assert ( + clone.baseline.gov.hmrc.national_insurance.class_1.rates.employee.main("2026") + == source_value + ) + + +def test_canonical_leaves_have_one_authoritative_registered_occurrence(): + tree = CountryTaxBenefitSystem().parameters + canonical_names = [PREFIX + name for name in LEAVES.values()] + descendants = list(tree.gov.get_descendants()) + for name in canonical_names: + canonical = tree.get_child(name) + assert isinstance(canonical, Parameter) + assert sum(item is canonical for item in descendants) == 1 + + +def test_standalone_legacy_clone_is_independent(): + tree = CountryTaxBenefitSystem().parameters + leaf = tree.gov.hmrc.national_insurance.class_1.rates.employee.main + independent = leaf.clone() + independent.update(period="2026", value=0.18) + assert leaf("2026") == 0.08 + assert independent("2026") == 0.18 + + +def test_processing_iteration_and_error_restoration_preserve_real_identity(): + from policyengine_core.parameters.operations.get_parameter import get_parameter + from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_ni_processing, + ) + + tree = CountryTaxBenefitSystem().parameters.clone() + legacy_parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + original_children = legacy_parent.children + legacy = original_children["main"] + canonical = tree.get_child(PREFIX + LEAVES["class_1.rates.employee.main"]) + canonical_parent = canonical.parent + before = build_parameters( + SimpleNamespace(tax_benefit_system=SimpleNamespace(parameters=tree)) + ) + with pytest.raises(RuntimeError, match="deliberate"): + with canonical_ni_processing(tree) as same: + assert same is tree + assert canonical.parent is canonical_parent + assert get_parameter(tree, PREFIX + "class_1.rates.employee.main") is legacy + assert not any( + isinstance(item, LegacyNIParameter) for item in tree.get_descendants() + ) + assert sum(item is canonical for item in tree.get_descendants()) == 1 + raise RuntimeError("deliberate failure") + assert legacy_parent.children is original_children + assert canonical.parent is canonical_parent + assert ( + build_parameters( + SimpleNamespace(tax_benefit_system=SimpleNamespace(parameters=tree)) + ) + == before + ) + + +def test_core_uprating_orders_legacy_dependencies_without_double_processing(): + from policyengine_core.parameters.operations.uprate_parameters import ( + uprate_parameters, + ) + from policyengine_uk.utils.ni_parameter_scales import ( + canonical_ni_processing, + canonical_ni_uprating_references, + ) + + tree = CountryTaxBenefitSystem().parameters.clone() + primary = tree.get_child(PREFIX + "class_1.thresholds.primary_threshold") + secondary = tree.get_child(PREFIX + "class_1.thresholds.secondary_threshold") + rate = tree.get_child(PREFIX + "class_1.rates.employee.main") + primary.update(start="2041-01-01", value=100) + primary.update(start="2042-01-01", value=200) + secondary.update(start="2041-01-01", value=50) + rate.update(start="2041-01-01", value=0.1) + secondary.metadata["uprating"] = PREFIX + "class_1.thresholds.primary_threshold" + rate.metadata["uprating"] = { + "parameter": PREFIX + "class_1.thresholds.secondary_threshold" + } + old_metadata = rate.metadata["uprating"] + with canonical_ni_processing(tree): + with canonical_ni_uprating_references(tree): + uprate_parameters(tree) + assert secondary("2042") == 100 + assert rate("2042") == 0.2 + assert rate.metadata["uprating"] is old_metadata + assert ( + tree.baseline.get_child(PREFIX + "class_1.rates.employee.main")("2042") == 0.08 + ) + + +def test_recursive_core_metadata_propagation_visits_each_authoritative_leaf_once(): + from policyengine_core.parameters.operations.propagate_parameter_metadata import ( + propagate_parameter_metadata, + ) + from policyengine_uk.utils.ni_parameter_scales import canonical_ni_processing + + tree = CountryTaxBenefitSystem().parameters.clone() + ni = tree.gov.hmrc.national_insurance + ni.metadata.update(propagate_metadata_to_children=True, scope_probe="owned-tree") + with canonical_ni_processing(tree): + propagate_parameter_metadata(tree) + for canonical in LEAVES.values(): + assert ( + tree.get_child(PREFIX + canonical).metadata["scope_probe"] == "owned-tree" + ) + assert ( + "scope_probe" + not in CountryTaxBenefitSystem().parameters.gov.hmrc.national_insurance.metadata + ) + + +@pytest.mark.parametrize( + "target", + ["legacy_leaf", "canonical_scale", "canonical_deleted", "canonical_leaf_deleted"], +) +def test_conflicting_structural_replacement_is_rejected_without_mutating_source(target): + from policyengine_core.parameters import ParameterNode, ParameterScale + + original = CountryTaxBenefitSystem() + changed = original.clone() + changed.reset_parameters() + ni = changed.parameters.gov.hmrc.national_insurance + if target == "legacy_leaf": + parent = ni.class_1.rates.employee + replacement = Parameter(parent.main.name, {"values": {"2026-01-01": 0.7}}) + parent.children["main"] = replacement + parent.main = replacement + replacement.parent = parent + elif target == "canonical_scale": + parent = ni.class_1 + replacement = ParameterScale( + parent.employee_scale.name, + { + "brackets": [ + {"threshold": {"2026-01-01": 0}, "rate": {"2026-01-01": 0.7}} + ] + }, + file_path=None, + ) + parent.children["employee_scale"] = replacement + parent.employee_scale = replacement + replacement.parent = parent + elif target == "canonical_deleted": + del ni.class_1.children["employee_scale"] + del ni.class_1.employee_scale + else: + bracket = ni.class_1.employee_scale.brackets[1] + del bracket.children["rate"] + del bracket.rate + with pytest.raises(ValueError, match="NI.*structurally replaced"): + changed.process_parameters() + assert ( + original.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + assert ( + original.parameters.get_child(PREFIX + "class_1.employee_scale[1].rate")("2026") + == 0.08 + ) + + +def test_supported_unrelated_structural_addition_survives_reset_and_processing(): + from policyengine_core.parameters import Parameter + + system = CountryTaxBenefitSystem() + leaf = Parameter("gov.test_222_extra", {"values": {"2026-01-01": 7}}) + system.parameters.gov.add_child("test_222_extra", leaf) + system.reset_parameters() + system.process_parameters() + assert system.parameters.gov.test_222_extra("2026") == 7 + assert ( + system.parameters.get_child(PREFIX + "class_1.rates.employee.main")("2026") + == 0.08 + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_mixed_alias_and_canonical_order_keeps_one_dated_timeline(reverse): + from policyengine_uk.utils.scenario import Scenario + from policyengine_uk import Simulation + + old = PREFIX + "class_1.rates.employee.main" + new = PREFIX + LEAVES["class_1.rates.employee.main"] + entries = [ + (old, {"2026-04-06.2027-04-05": 0.11}), + (new, {"2026-04-06.2027-04-05": 0.17}), + ] + if reverse: + entries.reverse() + sim = Simulation( + situation={ + "people": {"p": {"age": {2026: 40}}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + }, + scenario=Scenario.from_reform(dict(entries)), + ) + params = sim.tax_benefit_system.parameters + leaf = params.get_child(new) + assert leaf("2026") == (0.11 if reverse else 0.17) + assert leaf("2027") == 0.08 + assert params.get_child(old) is leaf + assert params.baseline.get_child(old)("2026") == 0.08 + assert all(a.path == new for a in sim.tax_benefit_system.dated_assignments) + + +@pytest.mark.parametrize( + "old, value", + [ + ("class_1.thresholds.primary_threshold", 300), + ("class_1.thresholds.upper_earnings_limit", 1100), + ("class_1.thresholds.secondary_threshold", 200), + ("class_4.thresholds.lower_profits_limit", 15000), + ("class_4.thresholds.upper_profits_limit", 60000), + ], +) +def test_all_five_generated_thresholds_keep_legacy_and_canonical_reform_routes( + old, value +): + from policyengine_uk.utils.scenario import Scenario + from policyengine_uk import Simulation + from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + INDEXED_THRESHOLDS, + ) + + new = PREFIX + LEAVES[old] + assert new in INDEXED_THRESHOLDS + situations = { + "people": { + "p": { + "age": {2031: 40}, + "employment_income": {2031: 60000}, + "self_employment_income": {2031: 20000}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + a, b = [ + Simulation( + situation=situations, + scenario=Scenario.from_reform({path: {"2031-04-06.2032-04-05": value}}), + ) + for path in (PREFIX + old, new) + ] + for year in (2030, 2031, 2032, 2034): + left, right = a.tax_benefit_system.parameters, b.tax_benefit_system.parameters + assert left.get_child(new)(str(year)) == right.get_child(new)(str(year)) + assert left.get_child(PREFIX + old) is left.get_child(new) + assert a.tax_benefit_system.parameters.get_child(new)("2031") == value + import numpy as np + + np.testing.assert_array_equal( + a.calculate("national_insurance", 2031), b.calculate("national_insurance", 2031) + ) + np.testing.assert_array_equal( + a.calculate("household_net_income", 2031), + b.calculate("household_net_income", 2031), + ) + + +def test_raw_history_and_april_sampling_remain_distinct(): + system = CountryTaxBenefitSystem() + system.reset_parameters() + ni = system.parameters.gov.hmrc.national_insurance + assert ni.class_1.employee_scale.brackets[1].rate("2022-11-05") == 0.1325 + assert ni.class_1.employee_scale.brackets[1].rate("2022-11-06") == 0.12 + assert ni.class_1.rates.employee.main("2022-11-05") == 0.1325 + assert ni.class_1.rates.employee.main("2022-11-06") == 0.12 + system.process_parameters() + assert ( + system.parameters.gov.hmrc.national_insurance.class_1.employee_scale("2022") + .components[1] + .rate + == 0.1325 + ) + assert ( + system.parameters.gov.hmrc.national_insurance.class_1.rates.employee.main( + "2022" + ) + == 0.1325 + ) + + +def test_public_component_inputs_retain_override_and_aggregation_contract(): + from policyengine_uk import Simulation + from policyengine_uk.tests.test_ni_scale_equivalence import situation + + data = situation() + data["people"]["p0"]["ni_class_1_employee_primary"] = {2026: 123} + data["people"]["p0"]["ni_class_1_employee_additional"] = {2026: 45} + sim = Simulation(situation=data) + assert float(sim.calculate("ni_class_1_employee", 2026)[0]) == 168 + assert float(sim.calculate("national_insurance", 2026)[0]) == 168 + + +def test_legacy_parent_preserves_core_tracing_and_fresh_cache_reads(): + from types import SimpleNamespace + from policyengine_core.tracers import TracingParameterNodeAtInstant + + tree = CountryTaxBenefitSystem().parameters.clone() + parent = tree.gov.hmrc.national_insurance.class_1.rates.employee + reads = [] + parent.trace = True + parent.tracer = SimpleNamespace( + record_parameter_access=lambda *args: reads.append(args) + ) + parent.branch_name = "test_222" + first = parent("2026") + assert isinstance(first, TracingParameterNodeAtInstant) + assert first.main == 0.08 + tree.get_child(PREFIX + "class_1.employee_scale[1].rate").update( + period="2026", value=0.21 + ) + assert parent("2026").main == 0.21 + assert [row[0] for row in reads] == [PREFIX + "class_1.rates.employee.main"] * 2 + assert [row[-1] for row in reads] == [0.08, 0.21] + + +_NI_ZERO_SCALES = ( + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", +) +_NI_ZERO_LEAVES = tuple( + (scale, leaf) for scale in _NI_ZERO_SCALES for leaf in ("threshold", "rate") +) + + +def _ni_zero_error(scale, leaf): + import re + + path = PREFIX + scale + "[0]." + leaf + return r"NI.*" + re.escape(path) + r".*fixed at zero" + + +def _ni_zero_situation(): + return { + "people": { + "p": { + "age": {2026: 40}, + "employment_income": {2026: 60_000}, + "self_employment_income": {2026: 30_000}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": { + "h": { + "members": ["p"], + "region": {2026: "LONDON"}, + "tenure_type": {2026: "OWNED_OUTRIGHT"}, + "council_tax": {2026: 0}, + } + }, + } + + +def _ni_zero_scenario(route, changes): + from policyengine_core.reforms import Reform + from policyengine_uk.utils.scenario import Scenario + + if route == "changes": + return Scenario(parameter_changes=changes) + if route == "core": + return Scenario.from_reform(Reform.from_dict(changes)) + if route == "callable": + + class UpdateNILeaves(Reform): + def apply(self): + def update(parameters): + for path, values in changes.items(): + parameters.get_child(path).update( + period="2026", value=values["2026"] + ) + return parameters + + self.modify_parameters(update) + + return Scenario.from_reform(UpdateNILeaves) + return Scenario.from_reform(changes) + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("route", ["dict", "changes", "core", "callable"]) +def test_ni_zero_band_reforms_are_rejected(scale, leaf, route): + from policyengine_uk import Simulation + + path = PREFIX + scale + "[0]." + leaf + changes = {path: {"2026": 0.5 if leaf == "rate" else 1}} + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + sim = Simulation( + situation=_ni_zero_situation(), + scenario=_ni_zero_scenario(route, changes), + ) + sim.calculate("national_insurance", 2026) + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("baseline", [False, True]) +def test_ni_zero_band_future_edits_cannot_hide_behind_a_cached_read( + scale, leaf, baseline +): + source = CountryTaxBenefitSystem().parameters + tree = source.clone() + target = tree.baseline if baseline else tree + target("2026") + path = PREFIX + scale + "[0]." + leaf + target.get_child(path).update(start="2041-01-01", value=1) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + target("2026") + assert source.get_child(path)("2041") == 0 + assert source.baseline.get_child(path)("2041") == 0 + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("operation", ["process_parameters", "reset_parameter_caches"]) +def test_ni_zero_band_validation_rejects_updates_without_mutating_source( + scale, leaf, operation +): + source = CountryTaxBenefitSystem() + changed = source.clone() + if operation == "process_parameters": + changed.reset_parameters() + path = PREFIX + scale + "[0]." + leaf + changed.parameters.get_child(path).update(period="2026", value=1) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + getattr(changed, operation)() + assert source.parameters.get_child(path)("2026") == 0 + + +@pytest.mark.parametrize("scale,leaf", _NI_ZERO_LEAVES) +@pytest.mark.parametrize("damage", ["missing", "null"]) +def test_ni_zero_band_malformed_leaves_raise_the_targeted_error(scale, leaf, damage): + tree = CountryTaxBenefitSystem().parameters.clone() + raw_scale = tree.get_child(PREFIX + scale) + bracket = raw_scale.brackets[0] + if damage == "missing": + del bracket.children[leaf] + delattr(bracket, leaf) + else: + bracket.children[leaf].update(period="2026", value=None) + with pytest.raises(ValueError, match=_ni_zero_error(scale, leaf)): + raw_scale("2026") + + +@pytest.mark.parametrize("scale", _NI_ZERO_SCALES) +@pytest.mark.parametrize("route", ["dict", "changes", "core", "callable"]) +def test_ni_zero_band_explicit_zero_updates_are_noops(scale, route): + import numpy as np + from policyengine_uk import Simulation + + changes = { + PREFIX + scale + "[0]." + leaf: {"2026": 0} for leaf in ("threshold", "rate") + } + original = Simulation(situation=_ni_zero_situation()) + changed = Simulation( + situation=_ni_zero_situation(), + scenario=_ni_zero_scenario(route, changes), + ) + for output in ("national_insurance", "ni_class_1_employer", "household_net_income"): + np.testing.assert_array_equal( + changed.calculate(output, 2026), original.calculate(output, 2026) + ) diff --git a/policyengine_uk/tests/test_ni_scale_equivalence.py b/policyengine_uk/tests/test_ni_scale_equivalence.py new file mode 100644 index 0000000000..05c86695bc --- /dev/null +++ b/policyengine_uk/tests/test_ni_scale_equivalence.py @@ -0,0 +1,156 @@ +"""Differential against frozen original NI formulas, plus independent cash oracles. + +The original formula sources are from UK e1601dbe4feb15e71d0d59b7a57f9d860741c068. +They restore only those formulas on the same parameter/input tree, not an entire +old model or historical legal entitlement. Parameter histories and metadata have +separate exact pre-migration fixtures; malformed reforms are compatibility tests. +""" + +import numpy as np +import pytest +from policyengine_core.reforms import Reform +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +ORIGINAL_SOURCES = { + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employee_primary(Variable):\n label = "NI Class 1 employee-side primary contributions"\n entity = Person\n definition_period = MONTH\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/8"\n\n def formula(person, period, parameters):\n income = person("ni_class_1_income", period)\n parameters = parameters(period).gov.hmrc.national_insurance.class_1\n\n # Thresholds are weekly, so multiply by weeks in year and divide by months in year\n primary_threshold = (\n parameters.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n upper_earnings_limit = (\n parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n\n upper_earnings_limit_income = max_(income - upper_earnings_limit, 0)\n primary_threshold_income = (\n max_(income - primary_threshold, 0) - upper_earnings_limit_income\n )\n\n return parameters.rates.employee.main * primary_threshold_income\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employee_additional(Variable):\n label = "NI Class 1 employee-side additional contributions"\n entity = Person\n definition_period = MONTH\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/8"\n\n def formula(person, period, parameters):\n income = person("ni_class_1_income", period)\n parameters = parameters(period).gov.hmrc.national_insurance.class_1\n upper_earnings_limit = (\n parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR\n )\n upper_earnings_limit_income = max_(income - upper_earnings_limit, 0)\n return parameters.rates.employee.additional * upper_earnings_limit_income\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_1_employer(Variable):\n value_type = float\n entity = Person\n label = "NI Class 1 employer-side contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_1_secondary_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/9"\n\n def formula(person, period, parameters):\n class_1 = parameters(period).gov.hmrc.national_insurance.class_1\n earnings = person("ni_class_1_income", period)\n if not parameters(\n period\n ).gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions:\n added_pension_contributions = person(\n "employer_pension_contributions", period\n )\n taxed_earnings = earnings + added_pension_contributions\n else:\n taxed_earnings = earnings\n secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR\n main_earnings = max_(\n taxed_earnings - secondary_threshold,\n 0,\n )\n return class_1.rates.employer * main_earnings\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4(Variable):\n value_type = float\n entity = Person\n label = "NI Class 4 contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_4_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/15"\n\n def formula(person, period, parameters):\n class_4 = parameters(period).gov.hmrc.national_insurance.class_4\n # Schedule 2: Class 4 is charged on the Chapter 2 trade profits less\n # the para 3 loss reliefs; Class 1 contributions are not a deduction.\n profits = person("ni_class_4_profits", period)\n add_rate_income = max_(\n profits - class_4.thresholds.upper_profits_limit,\n 0,\n )\n main_rate_income = (\n max_(\n profits - class_4.thresholds.lower_profits_limit,\n 0,\n )\n - add_rate_income\n )\n pre_maximum_amount = (\n main_rate_income * class_4.rates.main\n + add_rate_income * class_4.rates.additional\n )\n maximum_amount = person("ni_class_4_maximum", period)\n # Regulation 100(1): the annual maximum applies only where primary\n # Class 1 (or, before 6 April 2024, Class 2) contributions are also\n # payable for the year.\n includes_class_2 = class_4.annual_maximum.includes_class_2\n maximum_applies = (person("ni_class_1_employee", period) > 0) | np.logical_and(\n includes_class_2, person("ni_class_2", period) > 0\n )\n return max_(\n where(\n maximum_applies,\n min_(pre_maximum_amount, maximum_amount),\n pre_maximum_amount,\n ),\n 0,\n )\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4_main(Variable):\n value_type = float\n entity = Person\n label = "NI Class 4 main contributions"\n definition_period = YEAR\n unit = GBP\n defined_for = "ni_class_4_liable"\n reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/15"\n\n def formula(person, period, parameters):\n class_4 = parameters(period).gov.hmrc.national_insurance.class_4\n profits = person("ni_class_4_profits", period)\n add_rate_income = max_(\n profits - class_4.thresholds.upper_profits_limit,\n 0,\n )\n main_rate_income = (\n max_(\n profits - class_4.thresholds.lower_profits_limit,\n 0,\n )\n - add_rate_income\n )\n return main_rate_income * class_4.rates.main\n', + "policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py": 'from policyengine_uk.model_api import *\n\n\nclass ni_class_4_maximum(Variable):\n label = "NI Class 4 maximum liability"\n entity = Person\n definition_period = YEAR\n value_type = float\n unit = GBP\n reference = "https://www.legislation.gov.uk/uksi/2001/1004/regulation/100"\n\n def formula(person, period, parameters):\n ni = parameters(period).gov.hmrc.national_insurance\n upl = ni.class_4.thresholds.upper_profits_limit\n lpl = ni.class_4.thresholds.lower_profits_limit\n step_1 = upl - lpl\n # Regulation 100 writes its percentages out, but in every year from\n # 2015-16 they match the Class 4 rates: 9% and 2% (Steps Two, Five,\n # Eight and Nine) to 2021-22 and in 2023-24; 9.73% and 2.73% for\n # 2022-23 (Health and Social Care Levy (Repeal) Act 2022 Sch para\n # 6(2)); from 2024-25, 6% and 100/6 in Steps Two and Five (National\n # Insurance Contributions (Reduction in Rates) Act 2024 s 2(3)) with\n # Steps Eight and Nine still at 2%. So the steps read the rate\n # parameters.\n main_rate = ni.class_4.rates.main\n add_rate = ni.class_4.rates.additional\n step_2 = step_1 * main_rate\n # Class 2 contributions drop out of regulation 100 from 6 April 2024.\n includes_class_2 = ni.class_4.annual_maximum.includes_class_2\n class_2_weeks_addition = 53 * ni.class_2.flat_rate * includes_class_2\n step_3 = step_2 + class_2_weeks_addition\n class_2_contributions = person("ni_class_2", period) * includes_class_2\n primary_class_1_contributions = person("ni_class_1_employee_primary", period)\n step_4_raw = step_3 - class_2_contributions - primary_class_1_contributions\n step_4 = max_(step_4_raw, 0)\n # Case 1: Step Four exceeds primary Class 1 + Class 2 + main-rate\n # Class 4. Main-rate Class 4 is Step Two less the main rate on the\n # unused part of the main band, so this is equivalent to\n # main rate x unused band + 53 weekly Class 2 rates\n # > 2 x (primary Class 1 + Class 2).\n # Comparing the totals directly left the case to float32 rounding\n # when they were equal (profits above the UPL, no Class 1 or 2), and\n # a wrong Case 1 dropped the additional-rate band (#1878). The unused\n # band is exactly zero at or above the UPL, so that equality is now\n # decided exactly.\n profits = person("ni_class_4_profits", period)\n unused_main_band = clip(upl - profits.astype(np.float64), 0, step_1)\n case_1 = (step_4_raw >= 0) & (\n main_rate * unused_main_band + class_2_weeks_addition\n > 2\n * (\n class_2_contributions.astype(np.float64)\n + primary_class_1_contributions.astype(np.float64)\n )\n )\n # Step Five converts Step Four back to profits at the main rate. With a\n # zero main rate (a reform abolishing it) there is no main-rate\n # liability to limit, so the headroom is unlimited instead of 0 / 0.\n step_5 = np.divide(\n step_4,\n main_rate,\n out=np.full_like(step_4, np.inf),\n where=main_rate > 0,\n )\n step_6 = min_(upl, profits) - lpl\n step_7 = max_(0, step_6 - step_5)\n step_8 = step_7 * add_rate\n step_9 = max_(0, profits - upl) * add_rate\n\n return where(case_1, step_4, step_4 + step_8 + step_9)\n', +} + +ORIGINAL_CLASSES = [] +for source_path, source in ORIGINAL_SOURCES.items(): + namespace = {} + exec(compile(source, source_path, "exec"), namespace) + ORIGINAL_CLASSES.append(namespace[source_path.rsplit("/", 1)[-1][:-3]]) + + +class OriginalNIFormulas(Reform): + def apply(self): + for variable in ORIGINAL_CLASSES: + self.update_variable(variable) + + +def situation(): + facts = [(40, 30000, 0), (40, 60000, 18000), (70, 12000, 70000), (40, 0, 20000)] + people = { + f"p{i}": { + "age": {2026: age}, + "employment_income": {2026: wage}, + "self_employment_income": {2026: profit}, + } + for i, (age, wage, profit) in enumerate(facts) + } + return { + "people": people, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(facts))}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": {2026: "LONDON"}, + "tenure_type": {2026: "OWNED_OUTRIGHT"}, + "council_tax": {2026: 0}, + } + for i in range(len(facts)) + }, + } + + +PREFIX = "gov.hmrc.national_insurance." +REFORMS = [ + {}, + {PREFIX + "class_1.rates.employee.main": {"2026": 0}}, + { + PREFIX + "class_1.thresholds.primary_threshold": {"2026": 200}, + PREFIX + "class_1.thresholds.upper_earnings_limit": {"2026": 200}, + }, + { + PREFIX + "class_1.thresholds.primary_threshold": {"2026": 300}, + PREFIX + "class_1.thresholds.upper_earnings_limit": {"2026": 200}, + }, + { + PREFIX + "class_4.thresholds.lower_profits_limit": {"2026": 20000}, + PREFIX + "class_4.thresholds.upper_profits_limit": {"2026": 10000}, + }, + { + "gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions": { + "2026": False + } + }, +] + + +@pytest.mark.parametrize("changes", REFORMS) +def test_component_and_final_cash_equal_frozen_original_formulas(changes): + current = Simulation(situation=situation(), scenario=Scenario.from_reform(changes)) + original = Simulation( + situation=situation(), + scenario=Scenario.from_reform((OriginalNIFormulas, changes)), + ) + outputs = [ + "ni_class_1_employee_primary", + "ni_class_1_employee_additional", + "ni_class_1_employee", + "ni_class_1_employer", + "ni_class_4_main", + "ni_class_4_maximum", + "ni_class_4", + "national_insurance", + "household_net_income", + ] + for name in outputs: + expected = original.calculate(name, 2026) + actual = current.calculate(name, 2026) + np.testing.assert_array_equal(actual, expected, err_msg=name) + assert actual.dtype == expected.dtype + # Query order cannot change a later output or the independent source system. + np.testing.assert_array_equal( + current.calculate("household_net_income", 2026), + original.calculate("household_net_income", 2026), + ) + + +def test_natural_2026_independent_ni_cash_examples(): + sim = Simulation(situation=situation()) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employee", 2026), + [1394.4032, 3210.6008, 0, 0], + atol=0.002, + rtol=0, + ) + np.testing.assert_allclose( + sim.calculate("ni_class_1_employer", 2026), + [3750, 8250, 1050, 0], + atol=0.002, + rtol=0, + ) + assert float(sim.calculate("ni_class_4", 2026)[3]) == pytest.approx( + 445.8, abs=0.002 + ) + + +@pytest.mark.parametrize("route", ["dict", "core", "changes"]) +def test_scalar_and_canonical_leaf_reforms_have_same_natural_final_path(route): + old = {PREFIX + "class_1.rates.employee.main": {"2026": 0.1}} + new = {PREFIX + "class_1.employee_scale[1].rate": {"2026": 0.1}} + + def scenario(values): + if route == "changes": + return Scenario(parameter_changes=values) + if route == "core": + return Scenario.from_reform(Reform.from_dict(values)) + return Scenario.from_reform(values) + + a = Simulation(situation=situation(), scenario=scenario(old)) + b = Simulation(situation=situation(), scenario=scenario(new)) + np.testing.assert_array_equal( + a.calculate("ni_class_1_employee", 2026), + b.calculate("ni_class_1_employee", 2026), + ) + np.testing.assert_array_equal( + a.calculate("household_net_income", 2026), + b.calculate("household_net_income", 2026), + ) diff --git a/policyengine_uk/tests/test_ni_threshold_freeze.py b/policyengine_uk/tests/test_ni_threshold_freeze.py index 43cceb3712..f7fd758fa4 100644 --- a/policyengine_uk/tests/test_ni_threshold_freeze.py +++ b/policyengine_uk/tests/test_ni_threshold_freeze.py @@ -163,12 +163,26 @@ def test_no_threshold_falls_in_cash_terms(system, name): def test_frozen_year_values_cite_a_source(): """Every value dated in 2026-27 to 2030-31 carries a titled reference.""" for name, path in PATHS.items(): - file = ( - PARAMETER_DIR - / "gov/hmrc/national_insurance" - / (path.replace(".", "/") + ".yaml") - ) - values = yaml.safe_load(file.read_text())["values"] + component_paths = { + "primary_threshold": ("class_1/employee_scale.yaml", 1), + "upper_earnings_limit": ("class_1/employee_scale.yaml", 2), + "secondary_threshold": ("class_1/employer_scale.yaml", 1), + "lower_profits_limit": ("class_4/contribution_scale.yaml", 1), + "upper_profits_limit": ("class_4/contribution_scale.yaml", 2), + } + if name in component_paths: + relative, index = component_paths[name] + data = yaml.safe_load( + (PARAMETER_DIR / "gov/hmrc/national_insurance" / relative).read_text() + ) + values = data["brackets"][index]["threshold"]["values"] + else: + file = ( + PARAMETER_DIR + / "gov/hmrc/national_insurance" + / (path.replace(".", "/") + ".yaml") + ) + values = yaml.safe_load(file.read_text())["values"] dated = {str(instant): entry for instant, entry in values.items()} years = [2025, 2026] if name == "lower_earnings_limit" else FROZEN_YEARS for year in years: diff --git a/policyengine_uk/tests/test_parameter_cache_apis.py b/policyengine_uk/tests/test_parameter_cache_apis.py new file mode 100644 index 0000000000..08b6e5a822 --- /dev/null +++ b/policyengine_uk/tests/test_parameter_cache_apis.py @@ -0,0 +1,207 @@ +"""Dated parameter views survive the UK public cache-reset lifecycle. + +These are behavior-preservation controls for an ownership refactor. The +separate architecture gate checks that country code leaves Core cache fields +to Core; no tax-policy correction is asserted by these tests. +""" + +from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor + +import pytest +from policyengine_core.parameters import ParameterNode +from policyengine_core.reforms import Reform +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk import tax_benefit_system as tbs_module +from policyengine_uk.entities import BenUnit, Household, Person +from policyengine_uk.utils.dated_reforms import read_parameter_changes + + +def parameter_tree(): + def branch(value): + return {"nested": {"amount": {"values": {"2025-01-01": value}}}} + + # Keep the real UK subtrees, including NI scales and their compatibility + # views. Mutate only this clone, never the constructor's cached roots. + root = CountryTaxBenefitSystem().parameters.clone() + root.add_child("branch", ParameterNode("branch", data=branch(11))) + root.baseline.add_child("branch", ParameterNode("branch", data=branch(23))) + return root + + +def small_system(): + system = CountryTaxBenefitSystem.__new__(CountryTaxBenefitSystem) + TaxBenefitSystem.__init__(system, entities=[Person, BenUnit, Household]) + system.modify_parameters(lambda _: parameter_tree()) + system.dated_assignments = [] + system.legacy_overrides = [] + system._projected_timelines = {} + system._raw_parameters = None + system._parameters_before_reset = None + system.capital_gains_projection = None + return system + + +def readings(system): + root = system.parameters + return ( + system.get_parameters_at_instant("2026-01-01").branch.nested.amount, + root("2026-01-01").branch.nested.amount, + root.branch("2026-01-01").nested.amount, + root.branch.nested("2026-01-01").amount, + root.baseline("2026-01-01").branch.nested.amount, + root.baseline.branch.nested("2026-01-01").amount, + ) + + +def test_reset_refreshes_system_and_every_descendant_without_replacing_tree(): + system = small_system() + root = system.parameters + leaf = root.branch.nested.amount + baseline = root.baseline + assert readings(system) == (11, 11, 11, 11, 23, 23) + old_view = system.get_parameters_at_instant("2026-01-01") + # A structural modifier can edit a timeline in place before asking for a + # reset. This deliberately does not invoke Parameter.update's own clearing. + leaf.values_list[0].value = 31 + root.baseline.branch.nested.amount.values_list[0].value = 47 + system.reset_parameter_caches() + assert readings(system) == (31, 31, 31, 31, 47, 47) + assert system.parameters is root + assert root.branch.nested.amount is leaf + assert root.baseline is baseline + assert system.get_parameters_at_instant("2026-01-01") is not old_view + + +def test_reset_keeps_an_actual_reforms_current_edits_and_baseline_separate(): + original = small_system() + + class Changed(Reform): + def apply(self): + self.parameters.branch.nested.amount.update(period="2026", value=31) + + reformed = Changed(original) + root = reformed.parameters + assert readings(original) == (11, 11, 11, 11, 23, 23) + assert readings(reformed) == (31, 31, 31, 31, 23, 23) + CountryTaxBenefitSystem.reset_parameter_caches(reformed) + assert reformed.parameters is root + assert readings(reformed) == (31, 31, 31, 31, 23, 23) + assert readings(original) == (11, 11, 11, 11, 23, 23) + + +def test_resets_can_repeat_inside_a_public_parameter_modifier(): + system = small_system() + root = system.parameters + assert readings(system)[0] == 11 + + def modifier(parameters): + parameters.branch.nested.amount.values_list[0].value = 37 + system.reset_parameter_caches() + system.reset_parameter_caches() + return parameters + + TaxBenefitSystem.modify_parameters(system, modifier) + assert system.parameters is root + assert readings(system) == (37, 37, 37, 37, 23, 23) + + +def test_cloned_systems_clear_only_their_own_dated_views_in_threads(): + original = small_system() + assert readings(original)[0] == 11 + + def change(amount): + clone = original.clone() + assert readings(clone)[0] == 11 + clone.parameters.branch.nested.amount.values_list[0].value = amount + clone.reset_parameter_caches() + return readings(clone), clone.parameters + + with ThreadPoolExecutor(max_workers=3) as pool: + results = list(pool.map(change, [29, 31, 37])) + for (values, root), amount in zip(results, [29, 31, 37]): + assert values == (amount, amount, amount, amount, 23, 23) + assert root is not original.parameters + assert readings(original) == (11, 11, 11, 11, 23, 23) + + +@pytest.mark.parametrize("route", ["process", "assignments"]) +def test_a_failed_rebuild_restores_tree_timelines_and_readable_views( + monkeypatch, route +): + system = small_system() + root = system.parameters + timelines = system._projected_timelines + original_values = readings(system) + + def fail(*args, **kwargs): + raise RuntimeError("deliberate processor failure") + + monkeypatch.setattr(tbs_module, "_rebuilt_parameters_cache", OrderedDict()) + monkeypatch.setattr(tbs_module, "process_parameter_tree", fail) + with pytest.raises(RuntimeError, match="deliberate processor failure"): + if route == "process": + system.process_parameters() + else: + # Use a real statutory path so assignment validation succeeds; + # the injected processor failure must exercise the rollback. + system.apply_parameter_assignments( + read_parameter_changes( + {"gov.hmrc.income_tax.rates.uk[0].rate": {"2026": 0.31}} + ) + ) + assert system.parameters is root + assert system._projected_timelines is timelines + assert system._raw_parameters is None + assert system.dated_assignments == [] + assert readings(system) == original_values + + +def test_real_cold_and_warm_uk_construction_keeps_entities_and_default_tax( + monkeypatch, +): + for name in ( + "_processed_parameters_cache", + "_statutory_parameters_cache", + "_projected_timelines_cache", + ): + monkeypatch.setattr(tbs_module, name, None) + cold = CountryTaxBenefitSystem() + warm = CountryTaxBenefitSystem() + assert cold.parameters is not warm.parameters + path = "gov.hmrc.income_tax.allowances.personal_allowance.amount" + for system in (cold, warm): + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + for entity in system.entities: + assert entity.get_variable("age") is system.get_variable("age") + situation = { + "people": {"person": {"age": {2026: 40}, "employment_income": {2026: 30_000}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + for system in (cold, warm): + monkeypatch.setattr( + "policyengine_uk.simulation.CountryTaxBenefitSystem", system.clone + ) + simulation = Simulation(situation=situation) + assert simulation.calculate("income_tax", 2026).tolist() == pytest.approx( + [3486] + ) + + +def test_real_reload_and_processing_keep_structural_nodes_and_baseline(): + system = CountryTaxBenefitSystem() + added = ParameterNode( + "extra_structure", data={"amount": {"values": {"2025-01-01": 17}}} + ) + system.parameters.add_child("extra_structure", added) + path = "gov.hmrc.income_tax.allowances.personal_allowance.amount" + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + system.get_parameters_at_instant("2026-01-01") + system.reset_parameters() + system.process_parameters() + assert system.parameters.extra_structure("2026-01-01").amount == 17 + assert system.parameters.get_child(path)("2026-01-01") == 12_570 + assert system.parameters.baseline.get_child(path)("2026-01-01") == 12_570 diff --git a/policyengine_uk/tests/test_participation_correctness.py b/policyengine_uk/tests/test_participation_correctness.py new file mode 100644 index 0000000000..74468b1952 --- /dev/null +++ b/policyengine_uk/tests/test_participation_correctness.py @@ -0,0 +1,300 @@ +"""Participation contracts; controlled incentives are not population predictions.""" + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.dynamics import participation as p +from policyengine_uk.model_api import WEEKS_IN_YEAR + + +class PeriodSimulation: + """Small mutable simulation protocol, with observable cash and year storage.""" + + def __init__(self, pay, *, default=2025, roles=None, dtype=np.float32): + n = len(pay) + self.default = default + self.baseline = None + self.pay = {year: pd.Series(pay, dtype=dtype) for year in (2025, 2027)} + self.hours = { + year: pd.Series(np.where(np.array(pay) > 0, 1000, 0), dtype=dtype) + for year in (2025, 2027) + } + self.roles = roles or {year: np.ones(n, dtype=int) for year in (2025, 2027)} + self.populations = {"benunit": SimpleNamespace(members_entity_id=np.arange(n))} + + def calculate(self, name, period=None, **kwargs): + year = period or self.default + if name == "employment_income": + return self.pay[year].copy() + if name == "hours_worked": + return self.hours[year].copy() + if name == "adult_index": + return pd.Series(self.roles[year]) + if name == "household_net_income": + return pd.Series(np.repeat(500 + self.pay[year].sum(), len(self.pay[year]))) + if name == "household_weight": + return pd.Series(np.ones(len(self.pay[year]))) + raise AssertionError(name) + + def set_input(self, name, year, values): + target = self.pay if name == "employment_income" else self.hours + target[year] = pd.Series(np.asarray(values), dtype=target[year].dtype) + + def reset_calculations(self): + pass + + +def controlled( + monkeypatch, + pay, + elasticities, + changes, + *, + excluded=None, + imputed=None, + dtype=np.float32, +): + """Hold incentives fixed while executing the real response/bin/draw/writeback API.""" + sim = PeriodSimulation(pay, dtype=dtype) + sim.baseline = PeriodSimulation(pay, dtype=dtype) + n = len(pay) + elasticities = np.asarray(elasticities, dtype=float) + changes = np.asarray(changes, dtype=float) + if excluded is None: + excluded = np.zeros(n, dtype=bool) + if imputed is None: + imputed = np.repeat(5000.123456, n) + from policyengine_uk.dynamics import labour_supply + + monkeypatch.setattr( + labour_supply, + "calculate_excluded_from_labour_supply_responses", + lambda *args: np.asarray(excluded), + ) + monkeypatch.setattr(p, "calculate_earnings_quintile", lambda *args: np.ones(n)) + monkeypatch.setattr( + p, + "calculate_participation_elasticities", + lambda sim, quintile, year=None: elasticities.copy(), + ) + monkeypatch.setattr( + p, "impute_wages_for_nonworkers", lambda *args: np.asarray(imputed) + ) + + def gain(source, *args, **kwargs): + return pd.DataFrame( + { + "gain_to_work": 100 * (1 + changes) + if source is sim + else np.repeat(100.0, n), + "in_work_income": np.repeat(100.0, n), + "out_of_work_income": np.zeros(n), + } + ) + + monkeypatch.setattr(p, "calculate_gain_to_work", gain) + return sim + + +def test_requested_year_changes_real_parent_elasticity_and_cash(monkeypatch): + """Real public-input Simulation, isolated hypothetical loss of work incentive.""" + sim = Microsimulation( + situation={ + "people": { + "parent": { + "age": {"2025": 40, "2027": 42}, + "gender": "FEMALE", + "employment_income": {"2025": 20000, "2027": 20000}, + "hours_worked": {"2025": 1000, "2027": 1000}, + }, + "child": {"age": {"2025": 2, "2027": 4}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": {"home": {"members": ["parent", "child"]}}, + }, + reform={"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.19}}, + ) + + def gain(source, *args, **kwargs): + return pd.DataFrame( + { + "gain_to_work": [0.0, 0.0] if source is sim else [100.0, 100.0], + "in_work_income": [100.0, 100.0], + "out_of_work_income": [0.0, 0.0], + } + ) + + monkeypatch.setattr(p, "calculate_gain_to_work", gain) + result = p.apply_participation_responses(sim, 2027, count_adults=1, random_seed=10) + assert result["participation_elasticity"].iloc[0] == pytest.approx(0.932) + assert result["new_employment_income"].iloc[0] == 0 + assert sim.calculate("hours_worked", 2027).iloc[0] == 0 + assert sim.calculate("employment_income", 2025).iloc[0] == 20000 + assert np.asarray(sim.baseline.calculate("employment_income", 2027))[0] == 20000 + + +@pytest.mark.parametrize("outlier", [10.0, -10.0]) +def test_excluded_people_cannot_move_eligible_bins_or_surplus(monkeypatch, outlier): + sim = controlled(monkeypatch, [100, 0, 100], [0.1, 0.2, 0.3], [0.5, 0, 0]) + before = p.apply_participation_responses(sim, 2025) + sim = controlled( + monkeypatch, + [100, 0, 100, 999], + [0.1, 0.2, 0.3, outlier], + [0.5, 0, 0, 100], + excluded=[False, False, False, True], + ) + after = p.apply_participation_responses(sim, 2025) + for name in ( + "elasticity_group", + "surplus_participation_change", + "participation_change", + "new_employment_income", + "participation_response", + ): + np.testing.assert_array_equal(after[name], before[name]) + np.testing.assert_array_equal(before["surplus_participation_change"], [0, 0, 0]) + assert sim.pay[2025].iloc[3] == 999 + assert sim.hours[2025].iloc[3] == 1000 + + +def test_response_preserves_global_numpy_random_state(monkeypatch): + sim = controlled(monkeypatch, [100, 0], [0.1, 0.2], [0, 0]) + original = np.random.get_state() + try: + np.random.seed(987) + state = np.random.get_state() + p.apply_participation_responses(sim, 2025, random_seed=42) + after = np.random.get_state() + assert state[0] == after[0] + np.testing.assert_array_equal(state[1], after[1]) + assert state[2:] == after[2:] + finally: + np.random.set_state(original) + + +def test_requested_year_adult_role_controls_gain_and_restores_income(): + sim = PeriodSimulation([100, 0], roles={2025: [0, 1], 2027: [1, 0]}) + result = p.calculate_gain_to_work( + sim, 2027, count_adults=1, impute_nonworker_wages=False + ) + np.testing.assert_array_equal(result["gain_to_work"], [100, 0]) + np.testing.assert_array_equal(sim.pay[2027], [100, 0]) + np.testing.assert_array_equal(sim.pay[2025], [100, 0]) + + +@pytest.mark.parametrize("seed", [0, 1, 42, 123]) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_local_draws_match_independent_legacy_loop_and_hours(monkeypatch, seed, dtype): + pay = np.array([100, 0, 200, 0, 300, 0, 400, 0], dtype=dtype) + hours = np.where(pay > 0, 1000, 0).astype(dtype) + elasticities = np.arange(1, 9) / 10 + change = np.array([0, 1, -1, 0.5, -0.25, 2, 0, 1.0]) + imputed = np.array([0, 5000.123456, 0, 6000.321987, 0, 0, 0, -1.0]) + sim = controlled( + monkeypatch, + pay, + elasticities, + change / elasticities, + imputed=imputed, + dtype=dtype, + ) + expected = pay.copy() + flags = np.zeros(len(pay), dtype=bool) + rng = np.random.RandomState(seed) + # Independent scalar reference preserves the historical candidate draw order, + # including zero probabilities and wages that cannot produce entry. + for index, income in enumerate(pay): + if income > 0: + if rng.random_sample() < max(0, -change[index]): + expected[index] = 0 + flags[index] = True + for index, income in enumerate(pay): + if income == 0: + if rng.random_sample() < max(0, change[index]) and imputed[index] > 0: + expected[index] = dtype(imputed[index]) + flags[index] = True + expected_hours = hours.copy() + expected_hours[(pay == 0) & (expected > 0)] = 977.6 + expected_hours[(pay > 0) & (expected == 0)] = 0 + assert WEEKS_IN_YEAR == 52 + result = p.apply_participation_responses(sim, 2025, random_seed=seed) + np.testing.assert_allclose( + result["direct_participation_change"], change, rtol=0, atol=1e-15 + ) + np.testing.assert_array_equal(result["surplus_participation_change"], np.zeros(8)) + np.testing.assert_array_equal(sim.pay[2025], expected) + np.testing.assert_array_equal(result["participation_response"], flags) + np.testing.assert_array_equal(sim.hours[2025], expected_hours) + assert sim.pay[2025].dtype == np.dtype(dtype) + np.testing.assert_array_equal(sim.baseline.pay[2025], pay) + + +def test_no_baseline_is_an_early_noop(): + sim = PeriodSimulation([100, 0]) + assert p.apply_participation_responses(sim, 2027).empty + np.testing.assert_array_equal(sim.pay[2027], [100, 0]) + + +def test_no_eligible_people_and_constant_bins_are_safe(monkeypatch): + sim = controlled(monkeypatch, [100, 0], [0.2, 0.2], [1, -1], excluded=[True, True]) + assert p.apply_participation_responses(sim, 2025).empty + np.testing.assert_array_equal(sim.pay[2025], [100, 0]) + sim = controlled(monkeypatch, [100, 0], [0.2, 0.2], [0, 0]) + result = p.apply_participation_responses(sim, 2025) + assert result["elasticity_group"].nunique() == 1 + np.testing.assert_array_equal(result["participation_change"], [0, 0]) + + +def test_omitted_year_retains_default_elasticity_contract(): + sim = Simulation( + situation={ + "people": {"adult": {"age": 40, "gender": "MALE"}}, + "benunits": {"b": {"members": ["adult"]}}, + "households": {"h": {"members": ["adult"]}}, + } + ) + np.testing.assert_array_equal( + p.calculate_participation_elasticities(sim, np.array([3])), [0.136] + ) + + +@pytest.mark.parametrize("year, expected", [(2025, 0.453), (2027, 0.589)]) +def test_cohabiting_parent_uses_requested_year_and_couple_group(year, expected): + """Retain #2445 couple grouping and #1836 explicit-year demographics together. + + Existing OBR Table A1 third-quintile matrix: working-partner mothers use + 0.453 at child age2 and0.589 at age4. These are engineering routing oracles. + """ + sim = Microsimulation( + situation={ + "people": { + "mother": { + "age": {"2025": 40, "2027": 42}, + "gender": "FEMALE", + "employment_income": {"2025": 20000, "2027": 20000}, + }, + "partner": { + "age": {"2025": 40, "2027": 42}, + "gender": "MALE", + "employment_income": {"2025": 25000, "2027": 25000}, + }, + "child": {"age": {"2025": 2, "2027": 4}}, + }, + "benunits": { + "family": { + "members": ["mother", "partner", "child"], + "is_married": {"2025": False, "2027": False}, + } + }, + "households": {"home": {"members": ["mother", "partner", "child"]}}, + } + ) + assert sim.calculate("is_couple", year).iloc[0] + assert not sim.calculate("is_married", year).iloc[0] + actual = p.calculate_participation_elasticities(sim, np.array([3, 3, 3]), year) + assert actual[0] == pytest.approx(expected) diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 4df2f1f149..2e6c991df9 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -164,8 +164,17 @@ def _situation(people, year, overrides=None): return situation +# Without receipts supplied, the model reads a trading profit within the +# trading allowance (£1,000 from 2017-18, so in every year here) as fully +# relieved (ITTOIA 2005 s. 783AF). It is then not chargeable under Part 2 and +# so is not relevant UK earnings (FA 2004 s. 189(2)(b)). +TRADING_ALLOWANCE = 1_000 + + def _relevant_earnings(person): - return person["employment_income"] + max(0, person["self_employment_income"]) + profit = max(0, person["self_employment_income"]) + chargeable_profit = profit if profit > TRADING_ALLOWANCE else 0 + return person["employment_income"] + chargeable_profit def _with_contributions(people, contributions): diff --git a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py index 87929cd78a..1ce961f531 100644 --- a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py +++ b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py @@ -158,8 +158,9 @@ def schedule_iia_amount(drawn, year): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML households (child_amount_tax_credit_award.yaml), run on every -# invocation. Pension Credit earnings are net of SPC Regs Sch VI para 5, and -# the CTC family element is entered at its statutory £545. The last two pay +# invocation. Wages are gross: the first three families' £2,600 less the +# SPC Regs Sch VI para 5 couple disregard of £10 x 52 gives £2,080 once. +# The CTC family element is entered at its statutory £545. The last two pay # rent, so the order and differential checks reach Housing Benefit; they # enter the Housing Benefit claim, which the model otherwise infers from # reported benefits across the whole simulation. @@ -170,10 +171,12 @@ def schedule_iia_amount(drawn, year): "state_pension": 4_000, "savings_interest_income": 300, } -_INPUTS = {"would_claim_uc": False, "pension_credit_earnings": 2_080} +_INPUTS = {"would_claim_uc": False} _RENTED = {"savings": 18_000, "rent": 12_000, "tenure_type": "RENT_FROM_COUNCIL"} YAML_FAMILIES = [ - # CTC and WTC with Pension Credit: passported, no child amount. + # CTC and WTC with PC: pensions 8,000 + net earnings 2,080 + passported + # WTC 4,935 = income 15,015; GC is 332.95 x 52 - 15,015 = 2,298.40. + # A tax-credit award excludes the 76.79 x 52 = 3,993.08 child amount. ( [ { @@ -187,14 +190,16 @@ def schedule_iia_amount(drawn, year): {**_INPUTS, "CTC_family_element": 545}, {"savings": 10_000}, ), - # No tax credit claim: the child amount. + # No tax credit claim: pensions 8,000 + net earnings 2,080 = 10,080. + # With the child amount, GC is 17,313.40 + 3,993.08 - 10,080 = 11,226.48. ( [_GRANDPARENT, {"age": 70, "state_pension": 4_000}], [{"age": 10}], _INPUTS, {"savings": 10_000}, ), - # WTC alone. + # WTC alone also excludes the child amount; the first family's income + # 15,015 and GC 2,298.40 are unchanged because CTC is not PC income. ( [ {**_GRANDPARENT, "working_tax_credit_reported": 1}, @@ -204,7 +209,11 @@ def schedule_iia_amount(drawn, year): _INPUTS, {"savings": 10_000}, ), - # A WTC award at a nil rate. + # Gross wages 3,500 - Sch VI 520 = 2,980, not a pre-netted earnings + # input. With pensions 17,000, income 19,980 exceeds guarantee 17,313.40. + # WTC is max(0, 4,935 - 41% x (20,500 - 7,955)) = 0, still an award. + # Without that award the child amount gives GC 21,306.48 - 19,980 = + # 1,326.48, satisfying the existing positive-award boundary assertion. ( [ { @@ -216,7 +225,7 @@ def schedule_iia_amount(drawn, year): {"age": 70, "state_pension": 8_000}, ], [{"age": 10}], - {**_INPUTS, "pension_credit_earnings": 2_980}, + _INPUTS, {"savings": 10_000}, ), # A CTC award ends the Guarantee Credit and its Housing Benefit passport. diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py new file mode 100644 index 0000000000..cad0a87ef3 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -0,0 +1,338 @@ +"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI) +and of the net earnings they apply to (regs. 17(10), 17(11), 17A and 17B). + +Invariants, over generated benefit units in England and Scotland: + +1. Statute: the disregard equals min(net earnings, 52 x w), recomputed here + from the inputs and independent simulations: + - each person's own earnings, each source floored at nil, less their + primary Class 1 contributions, half of their pension contributions and, + on self-employed profit, the notional tax and contributions of CER 1996 + reg. 14 (basic rate on the profit less the personal allowance and any + marriage allowance; main-rate Class 4 under State Pension age), floored + at nil; + - less the income tax payable on the claimant's and partner's employed + earnings, measured as the fall in their income tax when the household + is simulated again with nobody's employment income. + w is 20 if the unit is a lone-parent family, or a claimant or partner is + entitled to carer's allowance (an input here), receives a listed + disability benefit or is blind; otherwise 5 single, 10 couple. A dependent + child's DLA or caring never qualifies. +2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). +3. Differential: Pension Credit income is the sources less tax, NI and half + of pension contributions, with the tax and contributions actually payable + on self-employed profit replaced by the notional ones, floored at 0, less + the disregard, floored at 0. +4. Monotonicity: the guarantee credit is never below its value without the + disregard (for units with nobody under State Pension age). +5. Earning more, employed or self-employed, by either partner, never raises + Pension Credit. +6. Under the disregard, Pension Credit income does not change with earnings. +7. The dated benefit lists follow the amending instruments: ESA from 27 + October 2008, PIP and AFIP from 8 April 2013. +8. The Scottish basic rate read for the notional tax is 20% in every year, + including those before the starter rate. +""" + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import WEEKS_IN_YEAR + +YEAR = 2024 +BASIC_RATE = 0.2 +PERSONAL_ALLOWANCE = 12_570 +CLASS_4_MAIN_RATE = 0.06 +LOWER_PROFITS_LIMIT = 12_570 +UPPER_PROFITS_LIMIT = 50_270 +STATE_PENSION_AGE = 66 +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DISABILITY = [ + "attendance_allowance", + "dla_sc", + "pip_dl", + "incapacity_benefit", + "esa_contrib", +] + + +@st.composite +def units(draw): + country = draw(st.sampled_from(["ENGLAND", "SCOTLAND"])) + couple = draw(st.booleans()) + child = draw(st.booleans()) + people = {} + names = ["claimant"] + (["partner"] if couple else []) + for n in names: + person = { + "age": draw( + st.sampled_from([67, 72, 85] + ([64] if n == "partner" else [])) + ), + "employment_income": draw(st.sampled_from([0, 50, 400, 1_040, 3_000])), + "self_employment_income": draw( + st.sampled_from([0, 0, 1_500, 15_000, -2_000]) + ), + "state_pension": draw(st.integers(min_value=0, max_value=12_000)), + "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), + "savings_interest_income": draw(st.sampled_from([0, 0, 3_000])), + "is_blind": draw(st.sampled_from([False, False, False, True])), + "is_entitled_to_carer_benefit": draw( + st.sampled_from([False, False, False, True]) + ), + "care_hours": draw(st.sampled_from([0, 0, 40])), + "personal_pension_contributions": draw(st.sampled_from([0, 0, 300])), + } + benefit = draw(st.sampled_from([None, None] + DISABILITY)) + if benefit: + person[benefit] = 2_000 + people[n] = person + if child: + # A dependent child's DLA or caring never qualifies the unit. + people["child"] = { + "age": 10, + "dla_sc": draw(st.sampled_from([0, 2_000])), + "is_entitled_to_carer_benefit": draw(st.booleans()), + } + return {"people": people, "country": country} + + +def simulate(case): + people = case["people"] + members = list(people) + dated = { + name: {k: {str(YEAR): v} for k, v in person.items()} + for name, person in people.items() + } + return Simulation( + situation={ + "people": dated, + "benunits": {"b": {"members": members}}, + "households": { + "h": {"members": members, "country": {str(YEAR): case["country"]}} + }, + } + ) + + +def without(case, sources): + """The case with nobody's income from each of ``sources``.""" + people = {n: dict(p) for n, p in case["people"].items()} + for person in people.values(): + for source in sources: + if source in person: + person[source] = 0 + return {"people": people, "country": case["country"]} + + +def unit_value(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + +def person_values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR, map_to="person"), dtype=float) + + +def weekly_amount(people): + adult_people = [p for n, p in people.items() if n != "child"] + couple = len(adult_people) == 2 + disabled = any( + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) + for p in adult_people + ) + carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) + lone_parent = "child" in people and not couple + return 20 if (disabled or carer or lone_parent) else (10 if couple else 5) + + +def notional_deductions(person, marriage_allowance): + """CER 1996 reg. 14 on the person's profit, from the inputs: no + generated adult is blind-person's-allowance relieved, born before 1935 + or liable for Class 2 (abolished in 2024-25).""" + profit = max(0, person.get("self_employment_income", 0)) + if profit == 0: + return 0.0 + tax = BASIC_RATE * max(0, profit - PERSONAL_ALLOWANCE - marriage_allowance) + class_4 = 0.0 + if person["age"] < STATE_PENSION_AGE: + class_4 = CLASS_4_MAIN_RATE * min( + max(0, profit - LOWER_PROFITS_LIMIT), + UPPER_PROFITS_LIMIT - LOWER_PROFITS_LIMIT, + ) + return tax + class_4 + + +@SETTINGS +@given(units()) +def test_statute_bounds_differential_and_monotonicity(case): + people = case["people"] + sim = simulate(case) + names = list(people) + adult = np.array([n != "child" for n in names]) + employment = np.array( + [max(0, people[n].get("employment_income", 0)) for n in names], dtype=float + ) + self_employment = np.array( + [max(0, people[n].get("self_employment_income", 0)) for n in names], + dtype=float, + ) + marriage_allowance = person_values(sim, "marriage_allowance") + notional = np.array( + [ + notional_deductions(people[n], marriage_allowance[i]) + for i, n in enumerate(names) + ] + ) + assert np.allclose( + person_values(sim, "pension_credit_self_employment_deductions"), + notional, + atol=0.01, + ) + class_1 = person_values(sim, "ni_class_1_employee") + contributions = 0.5 * person_values(sim, "pension_contributions") + own_net = np.maximum( + 0, employment + self_employment - class_1 - notional - contributions + ) + + income_tax = person_values(sim, "income_tax") + no_employment = person_values( + simulate(without(case, ["employment_income"])), "income_tax" + ) + no_earnings = person_values( + simulate(without(case, ["employment_income", "self_employment_income"])), + "income_tax", + ) + employment_tax = max(0.0, float((income_tax - no_employment)[adult].sum())) + self_employment_tax = max(0.0, float((no_employment - no_earnings)[adult].sum())) + net_earnings = max(0.0, float(own_net[adult].sum()) - employment_tax) + + expected = min(net_earnings, weekly_amount(people) * WEEKS_IN_YEAR) + disregard = unit_value(sim, "pension_credit_earnings_disregard") + assert np.isclose(disregard, expected, atol=0.01) + gross = float((employment + self_employment)[adult].sum()) + assert 0 <= disregard <= min(gross, 20 * WEEKS_IN_YEAR) + 0.01 + + sources = sim.tax_benefit_system.parameters( + f"{YEAR}-06-01" + ).gov.dwp.pension_credit.guarantee_credit.income + total = sum(float(sim.calculate(s, YEAR, map_to="benunit")[0]) for s in sources) + actual_self_employment = ( + self_employment_tax + + float(person_values(sim, "ni_class_2")[adult].sum()) + + float(person_values(sim, "ni_class_4")[adult].sum()) + ) + deductions = ( + float(income_tax[adult].sum()) + + float(person_values(sim, "national_insurance")[adult].sum()) + - actual_self_employment + + float(notional[adult].sum()) + + float(contributions[adult].sum()) + ) + before = max(0.0, total - deductions) + assert np.isclose( + unit_value(sim, "pension_credit_income"), + max(0.0, before - disregard), + atol=0.01, + ) + # Mixed-age couples (a partner under State Pension age) get no guarantee + # credit at all, so the comparison is for units entirely over that age. + if all(p["age"] >= STATE_PENSION_AGE for n, p in people.items() if n != "child"): + gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) + assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + + +EARNINGS_RISES = st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 260, 1_040, 5_000]), +) + + +def raised(case, rise): + name, source, extra = rise + if name not in case["people"]: + name = "claimant" + people = {n: dict(p) for n, p in case["people"].items()} + people[name][source] = people[name].get(source, 0) + extra + return {"people": people, "country": case["country"]} + + +@SETTINGS +@given(units(), EARNINGS_RISES) +def test_earning_more_never_raises_pension_credit(case, rise): + before = unit_value(simulate(case), "pension_credit_entitlement") + after = unit_value(simulate(raised(case, rise)), "pension_credit_entitlement") + assert after <= before + 0.01 + + +@st.composite +def units_under_the_disregard(draw): + """Units with small earnings and no pension contributions or losses, so + the disregard usually covers the net earnings and no floor on a person's + net earnings binds.""" + case = draw(units()) + for person in case["people"].values(): + person.pop("personal_pension_contributions", None) + if "employment_income" in person: + person["employment_income"] = draw(st.sampled_from([0, 50, 100])) + person["self_employment_income"] = draw(st.sampled_from([0, 0, 120])) + return case + + +@settings(SETTINGS, suppress_health_check=list(HealthCheck)) +@given( + units_under_the_disregard(), + st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 100]), + ), +) +def test_pension_credit_income_is_flat_in_earnings_under_the_disregard(case, rise): + sims = [simulate(case), simulate(raised(case, rise))] + cap = weekly_amount(case["people"]) * WEEKS_IN_YEAR + # Where the disregard covers all the net earnings before and after the + # rise, and Pension Credit income is not floored at nil. + assume( + all( + unit_value(sim, "pension_credit_earnings_disregard") < cap - 0.01 + and unit_value(sim, "pension_credit_income") > 0 + for sim in sims + ) + ) + before, after = (unit_value(sim, "pension_credit_income") for sim in sims) + assert np.isclose(before, after, atol=0.05) + + +def test_benefit_lists_follow_amending_instruments(): + from policyengine_uk.system import system + + def lists(date): + p = system.parameters(date).gov.dwp.pension_credit.earnings_disregard.higher + return set(p.disability_benefits), set(p.disability_benefit_unit_benefits) + + person, unit = lists("2008-10-26") + assert "esa_contrib" not in person and "esa_income" not in unit + person, unit = lists("2008-10-27") + assert "esa_contrib" in person and "esa_income" in unit + person, _ = lists("2013-04-07") + assert "pip" not in person and "armed_forces_independence_payment" not in person + person, _ = lists("2013-04-08") + assert {"pip", "armed_forces_independence_payment"} <= person + for date in ["2008-10-26", "2013-04-08", "2026-04-06"]: + person, _ = lists(date) + assert {"incapacity_benefit", "sda", "attendance_allowance", "dla"} <= person + + +def test_scottish_basic_rate_band_is_twenty_percent_in_every_year(): + from policyengine_uk.system import system + + bands = system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets + for year in range(2016, 2030): + assert bands[1].rate(f"{year}-06-01") == 0.2 diff --git a/policyengine_uk/tests/test_pension_credit_income_losses.py b/policyengine_uk/tests/test_pension_credit_income_losses.py index 94e4b5e535..c6d456d4f2 100644 --- a/policyengine_uk/tests/test_pension_credit_income_losses.py +++ b/policyengine_uk/tests/test_pension_credit_income_losses.py @@ -129,8 +129,9 @@ def test_issue_2255_reproducers(): ) # 20,000 less income tax of 20% of (20,000 - 12,570). assert property_loss == (pytest.approx(18_514, abs=1), 0) - # 238 x 52 = 12,376, less wages of 10,000. + # Wages of 10,000 less the 5 pound a week earnings disregard (SPC Regs + # 2002 Sch VI para 5), 260; 238 x 52 = 12,376, less 9,740. assert trading_loss == ( - pytest.approx(10_000, abs=1), - pytest.approx(2_376, abs=1), + pytest.approx(9_740, abs=1), + pytest.approx(2_636, abs=1), ) diff --git a/policyengine_uk/tests/test_pension_credit_late_inputs.py b/policyengine_uk/tests/test_pension_credit_late_inputs.py new file mode 100644 index 0000000000..390c351ee3 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_late_inputs.py @@ -0,0 +1,81 @@ +"""Explicit tax reliefs survive Pension Credit's earnings counterfactuals.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize("late", [False, True]) +def test_explicit_allowances_keep_couple_award_flat(late): + # Two AA recipients qualify for the £20/week combined disregard. + # The explicit £20,000 allowance leaves £148 pension tax after marriage + # allowance; £1,000 wages adds £200 tax and £800 net earnings, all ignored. + # PC income therefore stays £22,000 - £148 = £21,852, and PC £5,986.20. + situation = { + "people": { + "a": { + "age": {"2026": 70}, + "private_pension_income": {"2026": 22_000}, + "attendance_allowance": {"2026": 3_000}, + }, + "b": {"age": {"2026": 70}, "attendance_allowance": {"2026": 3_000}}, + }, + "benunits": {"b": {"members": ["a", "b"], "would_claim_pc": {"2026": True}}}, + "households": {"h": {"members": ["a", "b"]}}, + } + awards = [] + for wages, expected_tax, expected_disregard in [(0, 0, 0), (1_000, 200, 800)]: + data = deepcopy(situation) + data["people"]["a"]["employment_income"] = {"2026": wages} + if not late: + for name, allowance in [("a", 20_000), ("b", 12_570)]: + data["people"][name]["personal_allowance"] = {"2026": allowance} + sim = Simulation(situation=data) + if late: + sim.set_input("personal_allowance", 2026, [20_000, 12_570]) + assert sim.calculate("is_pension_credit_eligible", 2026)[0] + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(expected_tax) + assert sim.calculate("pension_credit_earnings_disregard", 2026)[ + 0 + ] == pytest.approx(expected_disregard) + assert sim.calculate("pension_credit_income", 2026)[0] == pytest.approx(21_852) + awards.append(sim.calculate("pension_credit", 2026)[0]) + np.testing.assert_array_equal( + sim.calculate("personal_allowance", 2026), [20_000, 12_570] + ) + assert sim.branches == {} + assert awards == pytest.approx([5_986.20, 5_986.20], abs=0.01) + + +def test_late_single_person_allowance_survives_counterfactual(): + # £18,000 pension is below the explicit £20,000 allowance. With £10,000 + # wages, £8,000 is taxed at 20%: the entire £1,600 is caused by employment. + sim = Simulation( + situation={ + "people": { + "a": { + "age": {"2026": 75}, + "private_pension_income": {"2026": 18_000}, + "employment_income": {"2026": 10_000}, + } + }, + "benunits": {"b": {"members": ["a"]}}, + "households": {"h": {"members": ["a"], "country": {"2026": "ENGLAND"}}}, + } + ) + sim.set_input("personal_allowance", 2026, [20_000]) + assert sim.calculate("income_tax", 2026)[0] == pytest.approx(1_600) + assert ( + sim.calculate("pension_credit_income_tax_without_employment_income", 2026)[0] + == 0 + ) + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(1_600) + np.testing.assert_array_equal(sim.calculate("personal_allowance", 2026), [20_000]) + assert sim.branches == {} diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py new file mode 100644 index 0000000000..9e177477da --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -0,0 +1,116 @@ +"""PC notional Class 2 uses CER chargeable income, before actual NI reliefs. + +CER 1996 reg. 14(2)(a), (3), at 6 April 2023: +https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06 +SSCBA 1992 s. 11(2), (4), at 6 April 2023: +https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06 +""" + +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize( + "profit, expected_deductions", + [(12_570, 0), (13_000, 304.10)], +) +def test_notional_class2_ignores_actual_trading_allowance(profit, expected_deductions): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {2023: 60}, + "self_employment_income": {2023: profit}, + "self_employment_gross_receipts": {2023: profit}, + } + } + } + ) + # Known receipts trigger the £1,000 trading allowance for actual NI. + assert simulation.calculate("ni_class_2", 2023)[0] == 0 + # CER chargeable income is profit before that allowance. At £13,000: + # tax = (£13,000 - £12,570) * 20% = £86; + # Class 4 = (£13,000 - £12,570) * 9% = £38.70; + # Class 2 = 52 * £3.45 = £179.40. Total = £304.10. + # At exactly £12,570 CER 14(2)(a) expressly excludes Class 2, and the + # personal allowance/lower profits limit make tax/Class 4 zero too. + assert simulation.calculate("pension_credit_self_employment_deductions", 2023)[ + 0 + ] == pytest.approx(expected_deductions, abs=0.01) + + +def test_other_trade_loss_keeps_protected_couple_notional_class2(): + results = [] + for loss in (0, 20_000): + simulation = Simulation( + situation={ + "people": { + "claimant": { + "age": {2023: 70}, + "attendance_allowance": {2023: 3_000}, + }, + "partner": { + "age": {2023: 60}, + "dla_sc_category": {2023: "MIDDLE"}, + "self_employment_income": {2023: 20_000}, + "trading_loss": {2023: loss}, + }, + }, + "benunits": { + "couple": { + "members": ["claimant", "partner"], + "has_mixed_age_couple_pension_credit_saving": {2023: True}, + "would_claim_pc": {2023: True}, + } + }, + "households": {"home": {"members": ["claimant", "partner"]}}, + } + ) + assert simulation.calculate("is_pension_credit_eligible", 2023)[0] + actual_class2 = simulation.calculate("ni_class_2", 2023)[1] + assert actual_class2 == pytest.approx(0 if loss else 179.40, abs=0.01) + # CER 13(12) prohibits another employment's loss reducing the + # £20,000 positive profit. Notional deductions are therefore + # (£20,000 - £12,570 PA - £1,260 marriage allowance) * 20% = + # £1,234 tax; plus £668.70 Class 4 and £179.40 Class 2 = £2,082.10. + deductions = simulation.calculate( + "pension_credit_self_employment_deductions", 2023 + )[1] + assert deductions == pytest.approx(2_082.10, abs=0.01) + results.append( + ( + deductions, + simulation.calculate("pension_credit_income", 2023)[0], + simulation.calculate("pension_credit", 2023)[0], + ) + ) + # SI 2023/316 art.30(2)(a), (d): the couple guarantee plus the higher + # severe-disability addition is (£306.85 + £152.80) * 52 = £23,901.80. + # https://www.legislation.gov.uk/uksi/2023/316/article/30/made + # PC income = £20,000 - £2,082.10 deductions - £1,040 disregard = + # £16,877.90; the award is £23,901.80 - £16,877.90 = £7,023.90. + assert results[0] == pytest.approx((2_082.10, 16_877.90, 7_023.90), abs=0.01) + assert results[1] == pytest.approx(results[0], abs=0.01) + + +@pytest.mark.parametrize("year, age", [(2023, 70), (2024, 60)]) +def test_notional_class2_respects_pension_age_and_abolition(year, age): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {year: age}, + "self_employment_income": {year: 13_000}, + "self_employment_gross_receipts": {year: 13_000}, + } + } + } + ) + # SSCBA s.11(7)(b) excludes pension-age earners, while SI 2024/377 + # reg.8(7) omits CER 14(2)(a) from 6 April 2024. Neither case has a + # notional Class 2 deduction. Class 4 is £25.80 at 2024's 6% main rate. + expected = 86 if year == 2023 else 86 + 25.80 + assert simulation.calculate("pension_credit_self_employment_deductions", year)[ + 0 + ] == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py index 67448845bd..cbdfaa5d61 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -54,6 +54,14 @@ household net income, excluding the TV licence fee, does not fall. The free TV licence for over-75s on Pension Credit is a genuine statutory cliff, so it is excluded, as in test_pension_credit_carer_income_properties.py. + Intended exception: Scottish pension-age Council Tax Reduction counts + Carer Support Payment before the CSP Regs reg 16(2) overlapping-benefit + reduction (SSI 2012/319 reg 27(3) and (5); see + council_tax_reduction_applicable_income), while Pension Credit counts it + after (SPC Regs reg 15(4)(g)). Where an overlapping benefit cuts the + payment, the passport ends on more Council Tax Reduction income than + Pension Credit counted, so the step can fall. Those families are left out + until that reading is decided. """ import numpy as np @@ -332,13 +340,20 @@ def test_net_income_does_not_fall_where_guarantee_credit_ends(families): "tv_licence", "housing_benefit", "council_tax_benefit", + "carer_support_payment_pre_overlap", + "carer_support_payment", ], ) step = np.diff(g["household_net_income"] + g["tv_licence"], axis=1) gc = g["guarantee_credit"] ends = (gc[:, :-1] > 0) & (gc[:, 1:] <= 0) + # The intended exception in invariant 4: Carer Support Payment cut by an + # overlapping benefit. + csp_cut = g["carer_support_payment_pre_overlap"] > g["carer_support_payment"] + 0.01 for i, fam in enumerate(families): for k in np.flatnonzero(ends[i]): + if csp_cut[i, k] or csp_cut[i, k + 1]: + continue assert step[i, k] >= -0.01, ( fam, f"private pension {PENSIONS[k]} -> {PENSIONS[k + 1]}", diff --git a/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py new file mode 100644 index 0000000000..811daf2a9d --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py @@ -0,0 +1,95 @@ +"""Share earnings-tax counterfactuals across PC and UC housing calculations.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.tests.test_uc_non_dep_pension_credit import YEAR, mixed_families + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def earnings_families(): + situation = mixed_families() + for name, wages, profit in ( + ("tenant", 18_000, 1_000), + ("pc_older", 1_000, 2_000), + ("pc_younger", 0, 100), + ("working_non_dep", 10_000, 0), + ("renter_older", 1_000, 1_000), + ("renter_younger", 0, 0), + ): + situation["people"][name]["employment_income"] = {YEAR: wages} + situation["people"][name]["self_employment_income"] = {YEAR: profit} + return situation + + +def count_counterfactuals(monkeypatch, sim): + variable = sim.tax_benefit_system.variables[ + "pension_credit_income_tax_without_earnings" + ] + namespace = variable.get_formula(YEAR).__globals__ + original = namespace["income_tax_with_inputs_zeroed"] + calls = [] + + def counted(simulation, period, variables): + calls.append((simulation.branch_name, tuple(variables))) + return original(simulation, period, variables) + + monkeypatch.setitem(namespace, "income_tax_with_inputs_zeroed", counted) + return calls + + +@pytest.mark.parametrize("first", ["pension_credit", "household_net_income"]) +def test_full_calculation_reuses_two_earnings_tax_counterfactuals(monkeypatch, first): + sim = Simulation(situation=earnings_families()) + calls = count_counterfactuals(monkeypatch, sim) + sim.calculate(first, YEAR) + sim.calculate("household_net_income", YEAR) + sim.calculate("pension_credit", YEAR) + # Both employment and self-employment are present. The UC housing + # non-dependant check shares the two counterfactuals with ordinary PC. + assert calls == [ + ("default", ("employment_income",)), + ("default", ("employment_income", "self_employment_income")), + ] + assert sim.branches == {} + + +def test_earnings_tax_reuse_preserves_calculation_order_and_changed_inputs(): + pc_first = Simulation(situation=earnings_families()) + income_first = Simulation(situation=earnings_families()) + pc_first.calculate("pension_credit", YEAR) + income_first.calculate("household_net_income", YEAR) + outputs = ( + "household_net_income", + "pension_credit", + "pension_credit_income", + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + "uc_non_dep_receives_pension_credit", + "universal_credit", + ) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + income_first.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + wages = [18_000, 50_000, 0, 10_000, 1_000, 0] + pc_first.set_input("employment_income", YEAR, wages) + pc_first.drop_computed_arrays() + changed_situation = earnings_families() + changed_situation["people"]["pc_older"]["employment_income"] = {YEAR: 50_000} + fresh = Simulation(situation=changed_situation) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + fresh.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + assert not pc_first.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + assert pc_first.branches == {} diff --git a/policyengine_uk/tests/test_pip_points_mixed_people.py b/policyengine_uk/tests/test_pip_points_mixed_people.py new file mode 100644 index 0000000000..655b222548 --- /dev/null +++ b/policyengine_uk/tests/test_pip_points_mixed_people.py @@ -0,0 +1,119 @@ +"""PIP categories are decided per person when some people have points. + +One person's points are not evidence about anyone else, so a person given +only an earlier category keeps it in a year when someone else has points. +""" + +import pytest + +from policyengine_uk import Simulation + + +def _two_adults(first_person: dict, second_person: dict) -> dict: + """Two adults aged 30 in separate benefit units and households in London.""" + people = {"a": first_person, "b": second_person} + situation = { + "people": {}, + "benunits": {}, + "households": {}, + } + for name, inputs in people.items(): + situation["people"][name] = {"age": {"2025": 30, "2026": 31}, **inputs} + situation["benunits"][f"benunit_{name}"] = {"members": [name]} + situation["households"][f"household_{name}"] = { + "members": [name], + "region": {"2025": "LONDON", "2026": "LONDON"}, + } + return situation + + +@pytest.mark.parametrize( + "component, points_variable, category_variable, amount_variable, " + "enhanced_2025, enhanced_2026", + [ + ("daily living", "pip_dl_points", "pip_dl_category", "pip", 5_740.80, 5_959.20), + ("mobility", "pip_m_points", "pip_m_category", "pip", 4_006.60, 4_160.00), + ], +) +def test_one_persons_points_keep_anothers_carried_category( + component, + points_variable, + category_variable, + amount_variable, + enhanced_2025, + enhanced_2026, +): + # A is assessed at 12 points in 2026. B has only an enhanced award from + # 2025 and no points in any year. + simulation = Simulation( + situation=_two_adults( + {points_variable: {"2026": 12}}, + {category_variable: {"2025": "ENHANCED"}}, + ) + ) + categories = { + year: list(simulation.calculate(category_variable, year)) + for year in (2025, 2026) + } + assert categories == { + 2025: ["NONE", "ENHANCED"], + 2026: ["ENHANCED", "ENHANCED"], + }, component + assert simulation.calculate(amount_variable, 2025).tolist() == pytest.approx( + [0, enhanced_2025], abs=0.05 + ) + assert simulation.calculate(amount_variable, 2026).tolist() == pytest.approx( + [enhanced_2026, enhanced_2026], abs=0.05 + ) + + +def test_supplied_zero_points_still_replace_an_earlier_category(): + # Zero points are an assessment, unlike points left out. + simulation = Simulation( + situation=_two_adults( + { + "pip_dl_category": {"2025": "ENHANCED"}, + "pip_dl_points": {"2026": 0}, + }, + {"pip_dl_category": {"2025": "ENHANCED"}}, + ) + ) + assert list(simulation.calculate("pip_dl_category", 2026)) == [ + "NONE", + "ENHANCED", + ] + + +def test_a_persons_earlier_points_carry_forward_past_anothers_later_points(): + # B was assessed at 12 points in 2025; A is assessed in 2026 and B is not. + simulation = Simulation( + situation=_two_adults( + {"pip_dl_points": {"2026": 12}}, + {"pip_dl_points": {"2025": 12}}, + ) + ) + assert list(simulation.calculate("pip_dl_category", 2026)) == [ + "ENHANCED", + "ENHANCED", + ] + assert simulation.calculate("pip", 2026).tolist() == pytest.approx( + [5_959.20, 5_959.20], abs=0.05 + ) + + +def test_a_persons_later_points_beat_their_own_earlier_category_only(): + # A had an enhanced award in 2025 and scores 8 points in 2026. B keeps + # their own 2025 standard award. + simulation = Simulation( + situation=_two_adults( + { + "pip_m_category": {"2025": "ENHANCED"}, + "pip_m_points": {"2026": 8}, + }, + {"pip_m_category": {"2025": "STANDARD"}}, + ) + ) + assert list(simulation.calculate("pip_m_category", 2026)) == [ + "STANDARD", + "STANDARD", + ] diff --git a/policyengine_uk/tests/test_program_registry.py b/policyengine_uk/tests/test_program_registry.py new file mode 100644 index 0000000000..f4214066d8 --- /dev/null +++ b/policyengine_uk/tests/test_program_registry.py @@ -0,0 +1,56 @@ +"""Keep the public program registry linked to real model objects.""" + +import pytest +import yaml +from policyengine_core.parameters import ParameterNode + +from policyengine_uk import CountryTaxBenefitSystem + +PROGRAMS = yaml.safe_load(CountryTaxBenefitSystem.modelled_policies.read_text())[ + "programs" +] + + +@pytest.fixture(scope="module") +def tax_benefit_system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "program", [p for p in PROGRAMS if "variable" in p], ids=lambda p: p["id"] +) +def test_program_variables_exist(program, tax_benefit_system): + assert program["variable"] in tax_benefit_system.variables, ( + f"{program['id']} references unknown variable {program['variable']!r}" + ) + + +@pytest.mark.parametrize( + "program", [p for p in PROGRAMS if "parameter_prefix" in p], ids=lambda p: p["id"] +) +def test_program_parameter_prefixes_exist(program, tax_benefit_system): + prefix = program["parameter_prefix"] + assert isinstance(tax_benefit_system.parameters.get_child(prefix), ParameterNode), ( + f"{program['id']} parameter prefix {prefix!r} does not name a parameter node" + ) + + +def test_maternity_allowance_does_not_advertise_unrelated_parameters(): + """Reported Maternity Allowance has no policy parameter subtree (#2116).""" + program = next(p for p in PROGRAMS if p["id"] == "maternity_allowance") + assert "parameter_prefix" not in program + + +def test_benefit_cap_does_not_advertise_unrelated_parameters(tax_benefit_system): + """The advertised Benefit Cap subtree excludes other DWP programs (#2116).""" + program = next(p for p in PROGRAMS if p["id"] == "benefit_cap") + advertised = tax_benefit_system.parameters.get_child(program["parameter_prefix"]) + benefit_cap = tax_benefit_system.parameters.gov.dwp.benefit_cap + + assert "universal_credit" not in advertised.children + assert "JSA" not in advertised.children + assert advertised.get_child("single.in_london") is benefit_cap.single.in_london + assert ( + advertised.get_child("non_single.outside_london") + is benefit_cap.non_single.outside_london + ) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 7abdfd7e02..2b6303b898 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -25,6 +25,7 @@ from policyengine_uk.data import UKSingleYearDataset from policyengine_uk.data import economic_assumptions from policyengine_uk.data.economic_assumptions import ( + COUNTRY_UPRATED_INPUTS, DATASET_UPRATING_EXCLUSIONS, LEGACY_PROJECTION_END_YEAR, PROJECTION_END_YEAR, @@ -54,6 +55,7 @@ and variable.value_type in (int, float) and name not in INDEX_OF and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } # These survey inputs already use FY2026-27 prices, so projection must not # apply their class-level CPI index a second time. @@ -79,6 +81,14 @@ "SCOTLAND": "scotland", "WALES": "wales", } +# Water and sewerage charges grow by country (#2501): Northern Ireland has +# none to grow. +WATER_SERIES = { + "LONDON": "ofwat.water_bills", + "NORTH_EAST": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "WALES": "ofwat.water_bills", +} def _dataset(amount=1_000.0, base_year=BASE_YEAR): @@ -110,6 +120,7 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): "tenure_type": [tenure for _, tenure in HOUSEHOLDS], "rent": amounts, "council_tax": amounts, + "water_and_sewerage_charges": amounts, }, } for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *ENERGY_VARIABLES): @@ -195,20 +206,14 @@ def test_each_singly_indexed_input_grows_by_its_index_every_year(projected): def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected): """Previously unmapped columns keep their old values through 2030, then grow by the same cumulative-index ratio Core used beyond that year. + Columns uprating_indices.yaml now lists (the debts, ISAs, lodger and + boarder rent, gross receipts, salary sacrifice and service charges), and + water bills, which grow by country, grow every year instead, so they are + no longer fallbacks. """ assert { - "mortgage_debt", - "consumer_debt", - "rent_paid_as_boarder", - "self_employment_gross_receipts", - "rent_paid_as_lodger", "rail_usage", - "stocks_and_shares_isa", - "cash_isa", - "water_and_sewerage_charges", "employee_pension_contributions_reported", - "pension_contributions_via_salary_sacrifice", - "housing_service_charges", } <= set(FALLBACK_INDEX_OF) for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): for variable, index_name in FALLBACK_INDEX_OF.items(): @@ -235,12 +240,25 @@ def test_reset_growthfactor_uprating_restores_unmapped_inputs(projected): reset = projected.copy() reset_growthfactor_uprating(reset) for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): - for variable in (*INDEX_OF, *FALLBACK_INDEX_OF): + for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *COUNTRY_UPRATED_INPUTS): base = _table(projected[BASE_YEAR], variable)[variable].to_numpy() current = _table(reset[year], variable)[variable].to_numpy() np.testing.assert_array_equal(current, base, err_msg=f"{variable}, {year}") +def test_water_charges_grow_by_country_every_projected_year(projected): + # Through the old horizon and after it, each country's own series grows + # the charge once a year; Northern Ireland's stays flat. + for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): + previous, current = projected[year - 1].household, projected[year].household + for i, (region, _) in enumerate(HOUSEHOLDS): + series = WATER_SERIES.get(region) + growth = 0.0 if series is None else GROWTH.get_child(series)(year) + assert current["water_and_sewerage_charges"].iloc[i] == pytest.approx( + previous["water_and_sewerage_charges"].iloc[i] * (1 + growth) + ), (region, year) + + def test_rent_and_council_tax_grow_every_projected_year(projected): for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): previous, current = projected[year - 1].household, projected[year].household diff --git a/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py index 8817cb7159..5760c24a64 100644 --- a/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py +++ b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py @@ -121,7 +121,7 @@ def test_main_residence_flag_never_creates_an_additional_purchase(households, ye price = out["main_residential_property_purchased"] first_home = np.array( [h["main_residential_property_purchased_is_first_home"] for h in households] - ) & (price < main.first.max) + ) & (price <= main.first.max) main_rates_tax = np.where( first_home, main.first.rate.calc(price), main.subsequent.calc(price) ) diff --git a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py index 043e485ff6..cc51cdb718 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py @@ -1,31 +1,9 @@ -"""Property-based tests: only people with pay have a salary sacrifice. - -From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -employment income (salary_sacrifice_returned_to_income). Only people with pay -have a salary sacrifice (pension_contributions_via_salary_sacrifice_from_pay), -so an input sacrifice with no pay behind it cannot create pay. A sacrifice -above the pay that remains (a whole bonus, say) is kept in full. - -Invariants, for any generated pay and salary sacrifice, in a year before the -cap (2028) and two years under it (2029, 2030): - -1. Definition: the sacrifice from pay is max(sacrifice, 0) with pay, else 0. -2. Conservation: the capped sacrifice plus the amount returned to income - equals the sacrifice from pay. -3. No pay, no gain: a person without pay has no employment income, nothing - returned and no National Insurance charge on a returned amount, whatever - their sacrifice input. -4. Nothing is returned before the cap starts. -5. Accounting: employment income equals pay plus the amount returned plus - the broad-base haircut. -6. Unchanged with pay: for a person with pay, the amount returned is the - sacrifice above the cap, as before this condition. -7. Monotonicity: the amount returned never falls when the sacrifice or the - pay rises. - -The simulations go through policyengine_uk.Simulation, which moves an -employment_income input to employment_income_before_lsr as it does for a -dataset or a household calculation. +"""Only people with pay have salary sacrifice and its NIC-only excess. + +The capped exempt amount plus NIC-only excess conserves the full employer +contribution. Ordinary employment income remains cash pay plus the existing +broad-base haircut. Generated pay, no-pay and whole-bonus inputs retain the +existing UKSimulation input convention and branch/reset compatibility. """ import numpy as np @@ -100,7 +78,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): adjusted = _values( simulation, "pension_contributions_via_salary_sacrifice_adjusted", year ) - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) employment_income = _values(simulation, "employment_income", year) haircut = _values(simulation, "salary_sacrifice_broad_base_haircut", year) cap = _cap(simulation, year) @@ -124,9 +102,9 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): # 4. Nothing is returned before the cap starts. if np.isinf(cap): assert np.all(returned == 0) - # 5. Accounting. + # 5. Ordinary cash-pay accounting; the excess is NIC-only. np.testing.assert_allclose( - employment_income, pays + returned + haircut, rtol=1e-6, atol=1e-2 + employment_income, pays + haircut, rtol=1e-6, atol=1e-2 ) # 6. Unchanged with pay. paid = pays > 0 @@ -141,7 +119,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): @PROPERTY_SETTINGS @given(people) -def test_returned_income_never_falls_with_sacrifice_or_pay(rows): +def test_ni_excess_never_falls_with_sacrifice_or_pay(rows): n = len(rows) pays = np.array([row[0] for row in rows]) sacrifices = np.array([row[1] for row in rows]) @@ -154,7 +132,7 @@ def test_returned_income_never_falls_with_sacrifice_or_pay(rows): np.concatenate([sacrifices, sacrifices + more_sacrifice, sacrifices]), ) for year in YEARS: - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) base, larger_sacrifice, larger_pay = ( returned[:n], returned[n : 2 * n], @@ -176,4 +154,4 @@ def test_pay_set_after_construction_keeps_the_sacrifice(): assert _values( simulation, "pension_contributions_via_salary_sacrifice_from_pay", 2030 ) == [6_000] - assert _values(simulation, "salary_sacrifice_returned_to_income", 2030) == [4_000] + assert _values(simulation, "salary_sacrifice_pension_ni_excess", 2030) == [4_000] diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py new file mode 100644 index 0000000000..b00849226e --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py @@ -0,0 +1,388 @@ +"""The pension sacrifice cap changes NIC earnings, not pay or member payments. + +NI Act 2026 ss1–2 and ITEPA 2003 s308 preserve the employer contribution. +FA 2004 ss188/190/191 make a synthetic member-payment offset unsafe at 75 +and where relief is limited. Haircut zero isolates classification from the +separate, unchanged employer-incidence assumption. Cash pay is already net +of contractual sacrifice, as required by the existing UKSimulation adapter. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEARS = (2028, 2029, 2030) +CAP = "gov.hmrc.national_insurance.salary_sacrifice_pension_cap" +HAIRCUT = "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate" +# age, cash wages, employer sacrifice, actual net-pay member payment, gross RAS +CASES = [ + (80, 160_000, 30_000, 0, 0), + (80, 100_000, 30_000, 0, 0), + (75, 100_000, 30_000, 1_000, 0), + (74, 1_500, 30_000, 0, 3_600), + (40, 49_000, 5_000, 0, 0), + (40, 0, 30_000, 0, 0), + (40, 40_000, 2_000, 0, 0), + (40, 40_000, 1_500, 0, 0), + (40, 40_000, 5_000, 1_000, 2_000), +] + + +def simulations(): + people = {} + for i, (age, wages, sacrifice, member, ras) in enumerate(CASES): + people[f"p{i}"] = { + name: {str(year): value for year in YEARS} + for name, value in { + "age": age, + "employment_income": wages, + "pension_contributions_via_salary_sacrifice": sacrifice, + "employee_pension_contributions": member, + "personal_pension_contributions": ras, + "employer_pension_contributions": 2_000, + }.items() + } + situation = { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + isolated = {HAIRCUT: {"2028-01-01.2030-12-31": 0}} + return ( + Simulation(situation=situation, reform=isolated), + Simulation( + situation=situation, + reform={**isolated, CAP: {"2029-01-01.2030-12-31": float("inf")}}, + ), + ) + + +@pytest.fixture(scope="module") +def pair(): + return simulations() + + +def values(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +def ordinary_tax(sim, year): + return values(sim, "income_tax", year) - values( + sim, "personal_pension_contributions_tax", year + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_nic_cap_preserves_ordinary_tax_pay_member_payments_and_allowances(pair, year): + capped, unlimited = pair + # These reach existing final variables, so RED must be a numerical failure, + # never a missing new variable or a forced final/intermediate value. + np.testing.assert_allclose( + ordinary_tax(capped, year), ordinary_tax(unlimited, year), atol=0.02, rtol=1e-6 + ) + for name in ( + "employment_income", + "employee_pension_contributions", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_relief", + ): + np.testing.assert_allclose( + values(capped, name, year), + values(unlimited, name, year), + atol=0.02, + rtol=1e-6, + err_msg=name, + ) + np.testing.assert_allclose( + values(capped, "employment_income", year), [c[1] for c in CASES] + ) + np.testing.assert_allclose( + values(capped, "employee_pension_contributions", year), [c[3] for c in CASES] + ) + assert values(capped, "pension_contributions_relief", year)[3] == 3_600 + + +def test_no_cap_before_2029_preserves_real_inputs(pair): + capped, unlimited = pair + np.testing.assert_allclose( + ordinary_tax(capped, 2028), ordinary_tax(unlimited, 2028) + ) + np.testing.assert_allclose( + values(capped, "employment_income", 2028), [c[1] for c in CASES] + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_actual_class_1_curves_charge_the_excess_once(pair, year): + capped, unlimited = pair + excess = np.array([28_000, 28_000, 28_000, 28_000, 3_000, 0, 0, 0, 3_000]) + wages = np.array([c[1] for c in CASES]) + np.testing.assert_allclose( + values(capped, "ni_class_1_income", year), wages + excess + ) + np.testing.assert_allclose(values(unlimited, "ni_class_1_income", year), wages) + p = capped.tax_benefit_system.parameters(year).gov.hmrc.national_insurance.class_1 + + # Independent piecewise annual curves, not the approximate reporting helper. + def employee(earnings): + pt, uel = ( + p.thresholds.primary_threshold * 52, + p.thresholds.upper_earnings_limit * 52, + ) + liability = ( + np.minimum(np.maximum(earnings - pt, 0), uel - pt) * p.rates.employee.main + + np.maximum(earnings - uel, 0) * p.rates.employee.additional + ) + return np.where( + np.array([False, False, False, False, True, True, True, True, True]), + liability, + 0, + ) + + expected_employee = employee(wages + excess) - employee(wages) + actual_employee = values(capped, "national_insurance", year) - values( + unlimited, "national_insurance", year + ) + np.testing.assert_allclose(actual_employee, expected_employee, atol=0.02, rtol=1e-6) + st = p.thresholds.secondary_threshold * 52 + expected_employer = ( + np.maximum(wages + excess - st, 0) - np.maximum(wages - st, 0) + ) * p.rates.employer + np.testing.assert_allclose( + values(capped, "ni_class_1_employer", year) + - values(unlimited, "ni_class_1_employer", year), + expected_employer, + atol=0.02, + rtol=1e-6, + ) + # £49k crosses the UEL: each slice, rather than all £3k at one rate. + assert 0 < expected_employee[4] < 3_000 * p.rates.employee.main + + +def test_dc_inputs_and_final_household_effect_conserve_real_payments(pair): + capped, unlimited = pair + for year in (2029, 2030): + expected = [ + (member + ras if age < 75 else 0) + 2_000 + (sacrifice if wages > 0 else 0) + for age, wages, sacrifice, member, ras in CASES + ] + np.testing.assert_allclose( + values(capped, "pension_contributions_for_annual_allowance", year), expected + ) + np.testing.assert_allclose( + values(unlimited, "pension_contributions_for_annual_allowance", year), + expected, + ) + # At £49k there is no AA excess, no benefit entitlement and no relief: + # the cap's only final household effect is actual employee NICs. + i = 4 + ni_delta = ( + values(capped, "national_insurance", year)[i] + - values(unlimited, "national_insurance", year)[i] + ) + for name, sign in ( + ("household_tax", 1), + ("household_net_income", -1), + ("hbai_household_net_income", -1), + ): + actual = values(capped, name, year)[i] - values(unlimited, name, year)[i] + assert actual == pytest.approx(sign * ni_delta, abs=0.02), name + + +def test_explicit_ordinary_return_input_remains_supported(): + sim = Simulation( + situation={ + "people": { + "p": { + "age": {"2026": 40}, + "employment_income": {"2026": 40_000}, + "employee_pension_contributions": {"2026": 1_000}, + "salary_sacrifice_returned_to_income": {"2026": 500}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + ) + assert values(sim, "employment_income", 2026)[0] == 40_500 + assert values(sim, "employee_pension_contributions", 2026)[0] == 1_500 + + +def test_ni_reporting_readers_retain_their_existing_approximation(pair): + capped, _ = pair + for year in (2029, 2030): + # These reporting measures deliberately retain the known #2363 + # approximation, including no pension-age gate. Actual NICs above + # are independently checked through national_insurance instead. + p = capped.tax_benefit_system.parameters( + year + ).gov.hmrc.national_insurance.class_1 + assert values(capped, "salary_sacrifice_pension_ni_employee", year)[ + 0 + ] == pytest.approx(28_000 * p.rates.employee.additional, abs=0.02) + assert values(capped, "salary_sacrifice_pension_ni_employer", year)[ + 0 + ] == pytest.approx(28_000 * p.rates.employer, abs=0.02) + assert np.all(values(capped, "salary_sacrifice_returned_to_income", year) == 0) + + +# A supplied legacy return is actual pay plus an actual member payment. It +# must agree with entering those amounts directly, including in the merged +# annual-allowance readers. These are distinct from a computed NICs excess. +ACTUAL_RETURN_CASES = [ + dict(age=40, pay=40_000, member=1_000, returned=500), + dict(age=75, pay=40_000, member=1_000, returned=500), + dict(age=74, pay=1_500, member=0, returned=500, ras=3_600), + dict(age=40, pay=40_000, member=1_000, returned=500, ani=39_000), + dict(age=75, pay=40_000, member=1_000, returned=500, ani=40_500), + dict(age=40, pay=200_000, member=5_000, returned=5_000, loss=400_000), + dict(age=40, pay=40_000, member=1_000, returned=500, dc=90_000), + dict( + age=40, + pay=200_000, + member=5_000, + returned=5_000, + employer=30_000, + sacrifice=30_000, + ), + dict(age=74, pay=40_000, member=1_000, returned=0, loss=100_000), +] +ACTUAL_RETURN_OUTPUTS = ( + "employment_income", + "employee_pension_contributions", + "pension_contributions", + "pension_contributions_relief", + "net_income", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + "income_tax", + "ni_class_1_income", + "national_insurance", + "household_tax", + "household_net_income", + "hbai_household_net_income", +) + + +def actual_return_simulations(cases): + def situation(explicit_return): + people = {} + for i, case in enumerate(cases): + returned = case["returned"] + inputs = { + "age": case["age"], + "employment_income": case["pay"] + (0 if explicit_return else returned), + "employee_pension_contributions": case["member"] + + (0 if explicit_return else returned), + "personal_pension_contributions": case.get("ras", 0), + "employer_pension_contributions": case.get("employer", 0), + "pension_contributions_via_salary_sacrifice": case.get("sacrifice", 0), + } + if explicit_return: + inputs["salary_sacrifice_returned_to_income"] = returned + for key, variable in ( + ("ani", "adjusted_net_income"), + ("loss", "trading_loss"), + ("dc", "pension_contributions_for_annual_allowance"), + ): + if key in case: + inputs[variable] = case[key] + people[f"p{i}"] = { + name: {"2026": amount} for name, amount in inputs.items() + } + return { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + + return tuple( + Simulation( + situation=situation(explicit_return), + reform={HAIRCUT: {"2026-01-01.2026-12-31": 0}}, + ) + for explicit_return in (True, False) + ) + + +@pytest.fixture(scope="module") +def actual_return_pair(): + return actual_return_groups() + + +def actual_return_groups(): + # A supplied variable is stored for the whole Person array. Keep natural + # cases separate from ANI and DC overrides so absent rows are not filled + # with zero inputs that mask the very formulas under test. + return [ + (indices, actual_return_simulations([ACTUAL_RETURN_CASES[i] for i in indices])) + for indices in ([0, 1, 2, 5, 7, 8], [3, 4], [6]) + ] + + +def actual_return_values(groups, member, variable): + result = np.empty(len(ACTUAL_RETURN_CASES)) + for indices, pair in groups: + result[indices] = values(pair[member], variable, 2026) + return result + + +@pytest.mark.parametrize("variable", ACTUAL_RETURN_OUTPUTS) +def test_explicit_actual_return_matches_actual_pay_and_member_inputs( + actual_return_pair, variable +): + np.testing.assert_allclose( + actual_return_values(actual_return_pair, 0, variable), + actual_return_values(actual_return_pair, 1, variable), + atol=0.02, + rtol=1e-6, + err_msg=variable, + ) + + +def test_explicit_actual_return_has_independent_aa_and_final_charge_oracles( + actual_return_pair, +): + # 2026 actual pay205000/member10000, employer30000 and sacrifice30000: + # DC70000; adjusted265000; threshold225000; taper(265000-260000)/2; + # allowance57500; excess12500, charged at the unchanged45% marginal rate. + expected = { + "employment_income": 205_000, + "employee_pension_contributions": 10_000, + "pension_contributions_for_annual_allowance": 70_000, + "pension_annual_allowance_adjusted_income": 265_000, + "pension_annual_allowance_threshold_income": 225_000, + "pension_annual_allowance": 57_500, + "personal_pension_contributions_tax": 5_625, + } + for member in (0, 1): + for variable, amount in expected.items(): + assert actual_return_values(actual_return_pair, member, variable)[ + 7 + ] == pytest.approx(amount, abs=0.02), variable + assert actual_return_values(actual_return_pair, member, "income_tax")[7] > 5_625 + + +def test_explicit_actual_return_is_query_order_independent(): + groups = actual_return_groups() + actual = { + name: actual_return_values(groups, 0, name) + for name in reversed(ACTUAL_RETURN_OUTPUTS) + } + for name in ACTUAL_RETURN_OUTPUTS: + np.testing.assert_allclose( + actual[name], actual_return_values(groups, 1, name), atol=0.02, rtol=1e-6 + ) diff --git a/policyengine_uk/tests/test_savings_credit_eligibility.py b/policyengine_uk/tests/test_savings_credit_eligibility.py index 04a2275d1c..7f3334a696 100644 --- a/policyengine_uk/tests/test_savings_credit_eligibility.py +++ b/policyengine_uk/tests/test_savings_credit_eligibility.py @@ -6,6 +6,10 @@ mixed-age Savings Credit continuity is inferred from reported Pension Credit. """ +from copy import deepcopy + +from hypothesis import example, given, settings +from hypothesis import strategies as st import numpy as np import pytest @@ -55,9 +59,9 @@ def situation(): return {"people": people, "benunits": benunits, "households": households} -def ungated_simulation(): +def ungated_simulation(data=None): """Retain the former amount formula for a downstream differential control.""" - sim = Simulation(situation=situation()) + sim = Simulation(situation=situation() if data is None else data) sim.tax_benefit_system.update_variable( type("savings_credit", (savings_credit,), {"defined_for": None}) ) @@ -150,3 +154,163 @@ def test_direct_savings_credit_input_keeps_its_existing_override_behavior(): assert sim.calculate("pension_credit_entitlement", YEAR)[0] == 0 assert sim.calculate("pension_credit", YEAR)[0] == 0 assert sim.calculate("housing_benefit", YEAR)[0] == 0 + + +# These are baseline input-contract invariants, not a proof of lawful historical +# Savings Credit continuity or population impacts. All components are computed; +# only the documented saving/closure scenario flags are supplied as inputs. +CONSUMER_OUTPUTS = [ + "guarantee_credit", + "pension_credit_entitlement", + "pension_credit", + "in_receipt_of_savings_credit_only", + "housing_benefit_applicable_income", + "housing_benefit_assessable_capital", + "housing_benefit_tariff_income", + "housing_benefit", + "council_tax_reduction_applicable_income", + "council_tax_reduction_assessable_capital", + "council_tax_reduction", + "targeted_childcare_entitlement", +] + + +def _control_unit(*, protected=False, closed=False, claim=False, both_old=False): + return { + "adults": [ + (80, 9_000 if both_old else (0 if closed else 20_000), 0, 0), + (78 if both_old else 60, 9_000 if both_old else 0, 0, 0), + ], + "protected": protected, + "closed": closed, + "claim_at_closure": claim, + "claim_pc": True, + "claim_uc": False, + "rent": 6_240, + "capital": 0, + "expected_left": protected and closed and claim, + } + + +@st.composite +def _unit_cases(draw): + shape = draw(st.sampled_from(["mixed", "both_old", "working"])) + ages = { + "mixed": (draw(st.integers(80, 90)), draw(st.integers(25, 60))), + "both_old": (draw(st.integers(80, 90)), draw(st.integers(80, 90))), + "working": (draw(st.integers(25, 60)), draw(st.integers(25, 60))), + }[shape] + return { + "adults": [ + ( + age, + draw(st.integers(0, 25_000)) if age >= 80 else 0, + draw(st.integers(0, 30_000)), + draw(st.integers(0, 10_000)), + ) + for age in ages + ], + "protected": draw(st.booleans()), + "closed": draw(st.booleans()), + "claim_at_closure": draw(st.booleans()), + "claim_pc": draw(st.booleans()), + "claim_uc": draw(st.booleans()), + "rent": draw(st.integers(0, 12_000)), + "capital": draw(st.integers(0, 20_000)), + } + + +def _generated_situation(units): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + members = [] + for j, (age, pension, earnings, private_pension) in enumerate(unit["adults"]): + name = f"person{i}_{j}" + members.append(name) + people[name] = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: True}, + "state_pension": {YEAR: pension}, + "employment_income": {YEAR: earnings}, + "private_pension_income": {YEAR: private_pension}, + } + benunits[f"b{i}"] = { + "members": members, + "has_mixed_age_couple_pension_credit_saving": {YEAR: unit["protected"]}, + "legacy_benefits_closed": {YEAR: unit["closed"]}, + "would_claim_uc_at_legacy_closure": {YEAR: unit["claim_at_closure"]}, + "would_claim_pc": {YEAR: unit["claim_pc"]}, + "would_claim_uc": {YEAR: unit["claim_uc"]}, + "would_claim_housing_benefit": {YEAR: True}, + } + households[f"h{i}"] = { + "members": members, + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: unit["rent"]}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: unit["capital"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def _assert_computed_component_invariants(sim): + # SPCA 2002 ss.1(2)-(4), 3 and 4(1A): shared entitlement qualifies the + # computed component. An explicit saving flag selects the existing route; + # it does not prove the separate pre-2016 Savings Credit history. + amount = sim.calculate("savings_credit", YEAR) + eligible = sim.calculate("is_pension_credit_eligible", YEAR) + assert np.all((amount <= 0) | eligible), ( + "positive computed SC requires shared eligibility" + ) + np.testing.assert_allclose( + sim.calculate("pension_credit_entitlement", YEAR), + sim.calculate("guarantee_credit", YEAR) + amount, + atol=0.01, + rtol=0, + ) + + +@settings(max_examples=25, derandomize=True, database=None, deadline=None) +@given(units=st.lists(_unit_cases(), min_size=1, max_size=6)) +@example(units=[_control_unit()]) +@example(units=[_control_unit(protected=True)]) +@example(units=[_control_unit(both_old=True)]) +@example(units=[_control_unit(protected=True, closed=True, claim=True)]) +def test_savings_credit_generated_invariants(units): + data = _generated_situation(units) + fixed = Simulation(situation=deepcopy(data)) + former = ungated_simulation(deepcopy(data)) + _assert_computed_component_invariants(fixed) + for i, unit in enumerate(units): + if "expected_left" in unit: + assert ( + bool(fixed.calculate("left_pension_route_at_legacy_closure", YEAR)[i]) + == unit["expected_left"] + ) + # Deliberately exclude raw Savings Credit: its unprotected off-route amount + # is precisely the value the shared gate changes. Consumers keep their gates. + for variable in CONSUMER_OUTPUTS: + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + former.calculate(variable, YEAR), + atol=0.01, + rtol=0, + err_msg=variable, + ) + + +def test_ungated_counterexample_detects_positive_component_without_entitlement(): + data = _generated_situation([_control_unit()]) + fixed = Simulation(situation=deepcopy(data)) + former = ungated_simulation(deepcopy(data)) + _assert_computed_component_invariants(fixed) + assert former.calculate("savings_credit", YEAR)[0] > 0 + assert not former.calculate("is_pension_credit_eligible", YEAR)[0] + # This negative control proves the invariant would detect losing the gate. + # Current main already has the gate; this is not a claimed production RED. + with pytest.raises( + AssertionError, match="positive computed SC requires shared eligibility" + ): + _assert_computed_component_invariants(former) diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py new file mode 100644 index 0000000000..f5df01c766 --- /dev/null +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -0,0 +1,246 @@ +"""ITA 2007 s21(3A): annual savings limit, September CPI and £10 ceilings. + +The £5,000 freeze ends in 2030-31 (FA 2026 s9). Future CPI in these +tests is an explicit scenario assumption, not a statutory future amount. +Savings-only income avoids the separately tracked starter-band taper #1799. +""" + +from decimal import Decimal +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.reforms import Reform + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.parameters.gov.hmrc import create_threshold_indexation +from policyengine_uk.utils.scenario import Scenario + +LIMIT = "gov.hmrc.income_tax.rates.savings_starter_rate.allowance" +CPI = "gov.economic_assumptions.statutory_uprating_inputs.cpi_september" +PA = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +PARAMETER_FILE = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml" +) + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +def limit(system, year, baseline=False): + root = system.parameters.baseline if baseline else system.parameters + return root.get_child(LIMIT)(f"{year}-01-01") + + +def with_cpi(system, rates): + result = system.clone() + result.reset_parameters() + parameter = result.parameters.get_child(CPI) + for year, rate in rates.items(): + parameter.update(period=f"year:{year}-09-01:1", value=rate) + result.process_parameters() + return result + + +@pytest.mark.parametrize("year", range(2026, 2031)) +def test_starting_rate_limit_is_frozen_through_2030(system, year): + assert limit(system, year) == 5_000 + + +def test_starting_rate_limit_has_annual_metadata_without_double_uprating(): + metadata = yaml.safe_load(PARAMETER_FILE.read_text())["metadata"] + assert metadata["period"] == "year" + assert "uprating" not in metadata + + +@pytest.mark.parametrize( + "previous,increase,expected", + [ + (5_000, "0.02", 5_100), + (5_100, "0.02", 5_210), + (5_210, "0.02", 5_320), + (5_000, "0.001", 5_010), + (5_000, "0.002", 5_010), + (5_000, "0", 5_000), + (5_100, "-0.01", 5_100), + ], +) +def test_starting_rate_statutory_rounding(previous, increase, expected): + assert create_threshold_indexation.index_savings_starting_rate_limit( + Decimal(previous), Decimal(increase) + ) == Decimal(expected) + + +def test_starting_rate_uses_the_previous_rounded_annual_limit(system): + indexed = with_cpi(system, dict.fromkeys(range(2030, 2039), 0.02)) + for year, expected in [(2030, 5_000), (2031, 5_100), (2032, 5_210), (2033, 5_320)]: + assert limit(indexed, year) == expected + if year > 2030: + assert limit(indexed, year, baseline=True) == expected + assert limit(indexed, 2040) == limit(indexed, 2039) + # Reapplying the helper does not append another year's increase. + create_threshold_indexation.add_threshold_indexation(indexed.parameters) + assert limit(indexed, 2031) == 5_100 + + +@pytest.mark.parametrize("rate", [0, -0.01]) +def test_nonpositive_cpi_preserves_the_preceding_limit(system, rate): + indexed = with_cpi(system, {2030: 0.02, 2031: rate}) + assert limit(indexed, 2031) == 5_100 + assert limit(indexed, 2032) == 5_100 + + +def savings_situation(): + years = range(2030, 2034) + return { + "people": { + "saver": { + "age": {year: 40 for year in years}, + "savings_interest_income": {year: 20_000 for year in years}, + } + }, + "benunits": {"benunit": {"members": ["saver"]}}, + "households": { + "household": { + "members": ["saver"], + "region": {year: "LONDON" for year in years}, + } + }, + } + + +@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"]) +def test_indexed_starting_rate_reaches_final_savings_income_tax(route): + # Hold the PA at £12,570 to isolate this band's effect. With £20,000 + # interest and the unchanged £1,000 PSA, £6,430 remains before the + # starting rate. The unchanged post-2027 basic savings rate is 22%. + changes = { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + } + if route == "parameter_changes": + simulation = Simulation( + situation=savings_situation(), + scenario=Scenario(parameter_changes=changes), + ) + else: + reform = changes if route == "dict" else Reform.from_dict(changes) + simulation = Simulation(situation=savings_situation(), reform=reform) + for year, starter, tax in [ + (2030, 5_000, 314.60), + (2031, 5_100, 292.60), + (2032, 5_210, 268.40), + (2033, 5_320, 244.20), + ]: + assert simulation.calculate("personal_allowance", year)[0] == 12_570 + assert simulation.calculate("savings_allowance", year)[0] == 1_000 + assert simulation.calculate("savings_starter_rate_income", year)[0] == starter + np.testing.assert_allclose( + simulation.calculate("savings_income_tax", year), [tax], rtol=0, atol=0.001 + ) + np.testing.assert_allclose( + simulation.calculate("income_tax", year), [tax], rtol=0, atol=0.001 + ) + + +def savings_simulation(route, changes): + if route == "parameter_changes": + return Simulation( + situation=savings_situation(), + scenario=Scenario(parameter_changes=changes), + ) + reform = changes if route == "dict" else Reform.from_dict(changes) + return Simulation(situation=savings_situation(), reform=reform) + + +@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"]) +def test_dated_limit_override_resumes_indexation_from_the_changed_limit(route): + # ITA 2007 s21(3A) uses the previous year's limit, including a reform's + # £6,000, then rounds the 2% increase up to £10: £6,120 in 2032. + # The separately constructed CPI/PA-only control retains its own path. + changes = { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + } + control = savings_simulation(route, changes) + simulation = savings_simulation( + route, {**changes, LIMIT: {"2031-04-06.2032-04-05": 6_000}} + ) + assert limit(simulation.tax_benefit_system, 2030) == 5_000 + assert limit(simulation.tax_benefit_system, 2031) == 6_000 + assert limit(simulation.tax_benefit_system, 2032) == 6_120 + assert limit(control.tax_benefit_system, 2032) == 5_210 + # With genuine £20,000 interest, the held PA and natural PSA leave + # £6,430 before the starting rate: (6,430 - limit) x 22%. + for current, year, tax in [ + (simulation, 2031, 94.60), + (simulation, 2032, 68.20), + (control, 2032, 268.40), + ]: + assert current.calculate("personal_allowance", year)[0] == 12_570 + assert current.calculate("savings_allowance", year)[0] == 1_000 + for variable in ("savings_income_tax", "income_tax"): + np.testing.assert_allclose( + current.calculate(variable, year), [tax], rtol=0, atol=0.001 + ) + + +@pytest.mark.parametrize( + "start,stop,expected", + [ + ("2029-04-06", "2030-04-29", (6_000, 5_000, 5_100, 5_210)), + ("2029-04-06", "2030-04-30", (6_000, 6_000, 6_120, 6_250)), + ("2030-04-06", "2030-04-29", (5_000, 5_000, 5_100, 5_210)), + ], +) +def test_override_replacing_the_last_file_value_keeps_uncovered_years( + start, stop, expected +): + # The file's last anchor is 2030-04-06. Replacing it must retain the + # stated 2030 freeze when the change ends before the 30 April sample. + # A change covering that sample instead supplies the previous-year + # base for 2031; £6,120 x 1.02 then ceilings to £6,250 for 2032. + simulation = savings_simulation( + "dict", + { + CPI: {"2030-09-01.2038-08-31": 0.02}, + LIMIT: {f"{start}.{stop}": 6_000}, + }, + ) + assert [ + limit(simulation.tax_benefit_system, year) for year in range(2029, 2033) + ] == list(expected) + + +def test_reset_and_reprocessing_keep_the_changed_savings_indexation_path(): + simulation = savings_simulation( + "dict", + { + CPI: {"2030-09-01.2038-08-31": 0.02}, + PA: {"2030-04-06.2034-04-05": 12_570}, + LIMIT: {"2031-04-06.2032-04-05": 6_000}, + }, + ) + system = simulation.tax_benefit_system + assert limit(system, 2032) == 6_120 + system.reset_parameters() + system.reset_parameters() + system.process_parameters() + assert [limit(system, year) for year in range(2030, 2033)] == [ + 5_000, + 6_000, + 6_120, + ] + assert system._parameters_before_reset is None + # Calculate after rebuilding, so this is not a cached pre-reset award. + assert simulation.calculate("personal_allowance", 2032)[0] == 12_570 + assert simulation.calculate("savings_allowance", 2032)[0] == 1_000 + for variable in ("savings_income_tax", "income_tax"): + np.testing.assert_allclose( + simulation.calculate(variable, 2032), [68.20], rtol=0, atol=0.001 + ) diff --git a/policyengine_uk/tests/test_sdlt_purchase_schedules.py b/policyengine_uk/tests/test_sdlt_purchase_schedules.py new file mode 100644 index 0000000000..49b215c0b7 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_purchase_schedules.py @@ -0,0 +1,158 @@ +"""Recent SDLT purchase schedules: raw legal dates versus annual sampling. + +Cash vectors are independent literals from FA 2003 s55/Sch6ZA, the SDLT +(Temporary Relief) Act 2023 s1 and Finance Act 2025 ss50-51. They describe +ordinary unprotected purchases, not contract protections or return rounding. +""" + +from pathlib import Path + +import numpy as np +import pytest +from policyengine_core.parameters import ParameterNode + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + + +PRICES = np.array([125_000, 250_000, 925_000, 1_500_000, 1_600_000]) +OLD_MAIN = [0, 2_500, 36_250, 93_750, 105_750] +TEMPORARY_MAIN = [0, 0, 33_750, 91_250, 103_250] + + +@pytest.fixture(scope="module") +def raw_purchase_parameters(): + # Bypass annual conversion only for raw legal-date scale tests. + return ParameterNode( + "purchase", + directory_path=str( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/stamp_duty/residential/purchase" + ), + ) + + +@pytest.mark.parametrize( + "date,main_cash,additional_cash,thresholds,additional_rates,ftb_nil,ftb_max", + [ + ( + "2022-09-22", + OLD_MAIN, + [3_750, 10_000, 64_000, 138_750, 153_750], + [0, 125_000, 250_000, 925_000, 1_500_000], + [0.03, 0.05, 0.08, 0.13, 0.15], + 300_000, + 500_000, + ), + ( + "2022-09-23", + TEMPORARY_MAIN, + [3_750, 7_500, 61_500, 136_250, 151_250], + [0, 250_000, 925_000, 1_500_000], + [0.03, 0.08, 0.13, 0.15], + 425_000, + 625_000, + ), + ( + "2024-10-30", + TEMPORARY_MAIN, + [3_750, 7_500, 61_500, 136_250, 151_250], + [0, 250_000, 925_000, 1_500_000], + [0.03, 0.08, 0.13, 0.15], + 425_000, + 625_000, + ), + ( + "2024-10-31", + TEMPORARY_MAIN, + [6_250, 12_500, 80_000, 166_250, 183_250], + [0, 250_000, 925_000, 1_500_000], + [0.05, 0.10, 0.15, 0.17], + 425_000, + 625_000, + ), + ( + "2025-03-31", + TEMPORARY_MAIN, + [6_250, 12_500, 80_000, 166_250, 183_250], + [0, 250_000, 925_000, 1_500_000], + [0.05, 0.10, 0.15, 0.17], + 425_000, + 625_000, + ), + ( + "2025-04-01", + OLD_MAIN, + [6_250, 15_000, 82_500, 168_750, 185_750], + [0, 125_000, 250_000, 925_000, 1_500_000], + [0.05, 0.07, 0.10, 0.15, 0.17], + 300_000, + 500_000, + ), + ], +) +def test_raw_purchase_schedules( + raw_purchase_parameters, + date, + main_cash, + additional_cash, + thresholds, + additional_rates, + ftb_nil, + ftb_max, +): + purchase = raw_purchase_parameters(date) + np.testing.assert_allclose( + purchase.main.subsequent.calc(PRICES), main_cash, rtol=0, atol=1e-8 + ) + np.testing.assert_allclose( + purchase.additional.rate.calc(PRICES), + additional_cash, + rtol=0, + atol=1e-8, + ) + # This also proves that null brackets disappear, rather than duplicating + # an active threshold during the temporary period. + assert purchase.main.subsequent.thresholds == thresholds + assert purchase.additional.rate.thresholds == thresholds + assert purchase.additional.rate.rates == additional_rates + assert purchase.main.first.max == ftb_max + np.testing.assert_allclose( + purchase.main.first.rate.calc(np.array([ftb_nil, ftb_nil + 20, ftb_max])), + [0, 1, 10_000], + rtol=0, + atol=1e-8, + ) + + +@pytest.fixture(scope="module") +def annual_system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "year,main300k,additional400k,first500k", + [ + (2022, 5_000, 22_000, 10_000), + (2023, 2_500, 19_500, 3_750), + (2024, 2_500, 19_500, 3_750), + (2025, 5_000, 30_000, 10_000), + (2027, 5_000, 30_000, 10_000), + ], +) +def test_annual_purchase_parameters_sample_april_30( + annual_system, year, main300k, additional400k, first500k +): + purchase = annual_system.parameters(year).gov.hmrc.stamp_duty.residential.purchase + np.testing.assert_allclose( + purchase.main.subsequent.calc(np.array([300_000])), main300k, rtol=0, atol=1e-8 + ) + np.testing.assert_allclose( + purchase.additional.rate.calc(np.array([400_000])), + additional400k, + rtol=0, + atol=1e-8, + ) + np.testing.assert_allclose( + purchase.main.first.rate.calc(np.array([500_000])), first500k, rtol=0, atol=1e-8 + ) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 439e77a639..7586378f28 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -7,12 +7,15 @@ period can differ from its type in the data year. All three components split by the period's type, so together they split the reported amount with no overlap and no gap: the part up to the type's full rate is uprated by the full -rate, and the part above it by the add-on's uprating (see add_on_uprating). +rate, and the part above it (additional pension, protected payments) by +September CPI, as the Up-rating Orders raise it (see add_on_uprating). The YAML runner builds simulations without a dataset, where the data year is the period by construction, so these cases build simulations from data. """ +from decimal import ROUND_HALF_UP, Decimal + import numpy as np import pandas as pd import pytest @@ -21,6 +24,8 @@ from policyengine_uk import Microsimulation from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.model_api import Scenario +from policyengine_uk.system import system DATA_YEAR = 2024 YEARS = range(DATA_YEAR, 2031) @@ -84,24 +89,82 @@ def flat_uprating(sim, year: int, pension_type: str) -> float: ) -def add_on_uprating(sim, year: int, pension_type: str) -> float: +# The rise each Social Security Benefits Up-rating Order gave additional +# pensions in long-term benefits, in April of the year: September CPI of the +# year before, under the Social Security Administration Act 1992 ss.150(1)(c) +# and 151(1). Since 2017 the same Orders raise the amounts of new State +# Pension at the transitional rate above the full rate (protected payments) by +# the same percentage, under s.151A. The 2016 Order (SI 2016/230) raised no +# additional pension: September 2015 CPI was -0.1%. For example, SI 2026/148 +# art 4(3)(a) and 6(3)(b) (3.8%) and SI 2025/295 art 4(3)(a) and 6(3)(b) +# (1.7%), against 4.8% and 4.1% for the full rates (arts 4(2) and 6(1)). +UP_RATING_ORDERS = { + 2011: ("SI 2011/821", 0.031), + 2012: ("SI 2012/780", 0.052), + 2013: ("SI 2013/574", 0.022), + 2014: ("SI 2014/516", 0.027), + 2015: ("SI 2015/457", 0.012), + 2016: ("SI 2016/230", 0.0), + 2017: ("SI 2017/260", 0.010), + 2018: ("SI 2018/281", 0.030), + 2019: ("SI 2019/480", 0.024), + 2020: ("SI 2020/234", 0.017), + 2021: ("SI 2021/162", 0.005), + 2022: ("SI 2022/292", 0.031), + 2023: ("SI 2023/316", 0.101), + 2024: ("SI 2024/242", 0.067), + 2025: ("SI 2025/295", 0.017), + 2026: ("SI 2026/148", 0.038), +} +# The model compounds the yearly rises into an index rounded to five decimal +# places each year (create_economic_assumption_indices.py), so it differs from +# the exact product of the rises by under 1e-5 of the amount. +INDEX_ROUNDING = 1e-5 + + +def add_on_rise(sim, year: int) -> float: + """The April rise in additional pensions and protected payments: the + Up-rating Order's where one has been made. For later years, the forecast + September CPI of the year before, as ONS publishes it (to 0.1 percentage + points), and no rise if prices fall (an Order under SSAA 1992 s.150(2) or + s.151A(2) is made only if prices rose).""" + if year in UP_RATING_ORDERS: + return UP_RATING_ORDERS[year][1] + parameters = sim.tax_benefit_system.parameters + inputs = parameters.gov.economic_assumptions.statutory_uprating_inputs + september = inputs.cpi_september(f"{year - 1}-09-01") + published = float( + Decimal(repr(float(september))).quantize(Decimal("0.001"), ROUND_HALF_UP) + ) + return max(published, 0.0) + + +def add_on_uprating(sim, year: int) -> float: """Uprating of the part above the full rate (additional pension, protected - payments). The model uprates it by the full rate; in law these rise with - prices (Social Security Benefits Up-rating Order 2026 arts 4(3) and 6(3)), - tracked in PolicyEngine/policyengine-uk#1941. Change it here with the model.""" - return flat_uprating(sim, year, pension_type) + payments) from the data year to ``year``: the yearly rises compounded. A + reference independent of the model's index, which follows the same rises + whatever the State Pension type (PolicyEngine/policyengine-uk#1941).""" + factor = 1.0 + for later_year in range(DATA_YEAR + 1, year + 1): + factor *= 1 + add_on_rise(sim, later_year) + return factor def uprated_reported(sim, year: int, pension_type: str, weekly: float) -> float: """The data-year amount, its part up to the full rate uprated by the full - rate and the part above it by the add-on's uprating.""" + rate and the part above it by September CPI.""" full = flat_rates(sim, DATA_YEAR)[pension_type] return WEEKS_IN_YEAR * ( min(weekly, full) * flat_uprating(sim, year, pension_type) - + max(weekly - full, 0) * add_on_uprating(sim, year, pension_type) + + max(weekly - full, 0) * add_on_uprating(sim, year) ) +def approx(expected: float): + """Exact to the penny, allowing for the model index's rounding.""" + return pytest.approx(expected, rel=INDEX_ROUNDING, abs=0.01) + + def calculate(sim, variable: str, year: int) -> np.ndarray: return np.asarray(sim.calculate(variable, year)) @@ -134,7 +197,7 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): 2014 on the basic State Pension; held at 72 she stands, by 2030-31, for a woman born in 1958, on the new State Pension. Her £240 a week exceeds the new State Pension's full rate, so the excess is additional State Pension - (a protected payment), uprated as the model uprates add-ons.""" + (a protected payment), uprated by September CPI.""" person = {"age": 72, "male": False, "months": 3, "weekly": 240} sim = projected_simulation([person]) assert calculate(sim, "state_pension_type", DATA_YEAR)[0] == "BASIC" @@ -143,9 +206,8 @@ def test_protected_payment_above_new_state_pension_follows_period_type(): assert calculate(sim, "new_state_pension", 2030)[0] == pytest.approx( rates["NEW"] * WEEKS_IN_YEAR, abs=0.01 ) - assert calculate(sim, "additional_state_pension", 2030)[0] == pytest.approx( - (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030, "NEW"), - abs=0.01, + assert calculate(sim, "additional_state_pension", 2030)[0] == approx( + (240 - data_rates["NEW"]) * WEEKS_IN_YEAR * add_on_uprating(sim, 2030) ) @@ -164,8 +226,8 @@ def test_unchanged_type_splits_at_its_own_flat_rate(person, pension_type): for year in YEARS: assert calculate(sim, "state_pension_type", year)[0] == pension_type total = sum(calculate(sim, v, year)[0] for v in COMPONENTS) - assert total == pytest.approx( - uprated_reported(sim, year, pension_type, person["weekly"]), abs=0.01 + assert total == approx( + uprated_reported(sim, year, pension_type, person["weekly"]) ) @@ -200,10 +262,174 @@ def test_new_in_data_year_and_basic_in_period_is_paid_in_full(): assert list(calculate(sim, "state_pension_type", DATA_YEAR)) == ["NEW", "NONE"] assert list(calculate(sim, "state_pension_type", 2030)) == ["BASIC", "NEW"] total = sum(calculate(sim, v, 2030) for v in COMPONENTS) - assert total[0] == pytest.approx( - uprated_reported(sim, 2030, "BASIC", 240), abs=0.01 + assert total[0] == approx(uprated_reported(sim, 2030, "BASIC", 240)) + assert total[1] == approx(uprated_reported(sim, 2030, "NEW", 230)) + + +# The add-on rises with prices (PolicyEngine/policyengine-uk#1941). Records are +# built from 2024-25 data, where the full rates are £169.50 (basic State +# Pension, Social Security Benefits Up-rating Order 2024 art 4(2)) and £221.20 +# (new State Pension, art 6(1)). + + +def test_additional_pension_rises_with_september_cpi_not_the_basic_rate(): + """A man aged 90 in 2024-25 reached State Pension age in 1999, on the basic + State Pension, as does a man aged 90 in any later year. He reports £200 a + week, £30.50 above the 2024-25 full basic rate. The Up-rating Orders raised + the basic State Pension by 4.1% and 4.8% and additional pensions by 1.7% + and 3.8% (SI 2025/295 and SI 2026/148, arts 4(2) and 4(3)(a)), so in + 2026-27 he gets £184.90 a week of basic State Pension and £30.50 x 1.017 x + 1.038 of additional State Pension.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 200} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "BASIC" + additional_2025 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 + additional_2026 = (200 - 169.50) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", DATA_YEAR)[0] == approx( + (200 - 169.50) * WEEKS_IN_YEAR + ) + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + additional_2025 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx( + additional_2026 + ) + assert calculate(sim, "basic_state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 184.90 * WEEKS_IN_YEAR + additional_2026 + ) + + +def test_protected_payment_rises_with_september_cpi_not_the_new_rate(): + """A man aged 70 in 2024-25 reached State Pension age in 2020, on the new + State Pension, as does a man aged 70 in 2026-27. He reports £240 a week, + £18.80 above the 2024-25 full rate: a protected payment. The Up-rating + Orders raised the full rate by 4.1% and 4.8% and protected payments by + 1.7% and 3.8% (SI 2025/295 and SI 2026/148, arts 6(1) and 6(3)(b)), so in + 2026-27 he gets £241.30 a week of new State Pension and £18.80 x 1.017 x + 1.038 of protected payment.""" + person = {"age": 70, "male": True, "months": 3, "weekly": 240} + sim = projected_simulation([person]) + for year in (DATA_YEAR, 2025, 2026): + assert calculate(sim, "state_pension_type", year)[0] == "NEW" + protected_2026 = (240 - 221.20) * WEEKS_IN_YEAR * 1.017 * 1.038 + assert calculate(sim, "additional_state_pension", 2025)[0] == approx( + (240 - 221.20) * WEEKS_IN_YEAR * 1.017 + ) + assert calculate(sim, "additional_state_pension", 2026)[0] == approx(protected_2026) + assert calculate(sim, "new_state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + ) + assert calculate(sim, "state_pension", 2026)[0] == approx( + 241.30 * WEEKS_IN_YEAR + protected_2026 + ) + + +@pytest.mark.parametrize( + "person", + [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ], + ids=["additional_pension", "protected_payment"], +) +def test_add_on_grows_each_year_by_that_years_september_cpi_rise(person): + """Each April the add-on rises by the September CPI of the year before: + the Orders' 1.7% and 3.8%, then the forecast.""" + sim = projected_simulation([person]) + for year in YEARS[1:]: + growth = ( + calculate(sim, "additional_state_pension", year)[0] + / calculate(sim, "additional_state_pension", year - 1)[0] + ) + assert growth == pytest.approx(1 + add_on_rise(sim, year), abs=INDEX_ROUNDING) + + +def test_september_cpi_uprating_matches_the_up_rating_orders(): + """The index the add-on follows rises each April by the percentage the + Up-rating Order gave additional pensions that year, 0% in April 2016.""" + parameters = system.parameters + indices = parameters.gov.economic_assumptions.indices + yoy_growth = parameters.gov.economic_assumptions.yoy_growth + for year, (order, rise) in UP_RATING_ORDERS.items(): + assert yoy_growth.september_cpi_uprating(year) == pytest.approx(rise), order + growth = indices.september_cpi_uprating(year) / indices.september_cpi_uprating( + year - 1 + ) + assert growth == pytest.approx(1 + rise, abs=INDEX_ROUNDING), order + + +@pytest.mark.parametrize( + "change", + [ + # Full rates set by reform from 2026-27: the add-on is not scaled with + # them. + { + "reform": { + "gov.dwp.state_pension.basic_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + "gov.dwp.state_pension.new_state_pension.amount": { + "2026-01-01.2100-12-31": 300.0 + }, + } + }, + # A 10% triple-lock floor from April 2027 raises the full rates, not + # the add-on. + { + "scenario": Scenario( + parameter_changes={ + "gov.dwp.state_pension.triple_lock.minimum_rate": { + "year:2027-01-01:100": 0.1 + }, + }, + applied_before_data_load=True, + ) + }, + ], + ids=["full_rates", "triple_lock_floor"], +) +def test_full_rate_reforms_leave_the_add_on_alone(change): + people = [ + {"age": 90, "male": True, "months": 3, "weekly": 250}, + {"age": 70, "male": True, "months": 3, "weekly": 240}, + ] + baseline = projected_simulation(people) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), **change) + for year in (2027, 2028, 2030): + assert calculate(reformed, "state_pension", year).sum() != pytest.approx( + calculate(baseline, "state_pension", year).sum() + ) + assert calculate(reformed, "additional_state_pension", year) == pytest.approx( + calculate(baseline, "additional_state_pension", year), abs=0.01 + ) + + +def test_a_september_cpi_scenario_moves_the_add_on(): + """The index is read from the simulation's own parameters: with September + 2027 CPI set to 1%, the add-on rises 1% in April 2028, while the full + rates rise by at least the triple lock's 2.5%.""" + person = {"age": 90, "male": True, "months": 3, "weekly": 250} + scenario = Scenario( + parameter_changes={ + "gov.economic_assumptions.statutory_uprating_inputs.cpi_september": { + "2027": 0.01 + }, + }, + applied_before_data_load=True, + ) + sim = Microsimulation(dataset=year_dataset([person], DATA_YEAR), scenario=scenario) + growth = ( + calculate(sim, "additional_state_pension", 2028)[0] + / calculate(sim, "additional_state_pension", 2027)[0] + ) + assert growth == pytest.approx(1.01, abs=INDEX_ROUNDING) + assert flat_uprating(sim, 2028, "BASIC") / flat_uprating(sim, 2027, "BASIC") >= ( + 1.025 - INDEX_ROUNDING ) - assert total[1] == pytest.approx(uprated_reported(sim, 2030, "NEW", 230), abs=0.01) # Each example builds a microsimulation, so examples are slow to generate. @@ -255,11 +481,7 @@ def assert_components_add_up(sim, people: list, years) -> None: flat_uprating(sim, year, "BASIC"), flat_uprating(sim, year, "NEW"), ) - add_up = by_type( - pension_type, - add_on_uprating(sim, year, "BASIC"), - add_on_uprating(sim, year, "NEW"), - ) + add_up = add_on_uprating(sim, year) reported = weekly * WEEKS_IN_YEAR assert np.array_equal(pension_type == "NONE", ~over_spa), year flat = basic + new @@ -267,8 +489,7 @@ def assert_components_add_up(sim, people: list, years) -> None: assert np.allclose(flat, expected_flat, rtol=1e-5, atol=0.01) deflated = np.where( over_spa, - flat / np.where(over_spa, flat_up, 1) - + additional / np.where(over_spa, add_up, 1), + flat / np.where(over_spa, flat_up, 1) + additional / add_up, 0, ) assert np.allclose(deflated, over_spa * reported, rtol=1e-5, atol=0.01) @@ -314,10 +535,136 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data assert_components_add_up(sim, people, (DATA_YEAR, period)) +def main_components(sim, weekly: np.ndarray, year: int) -> tuple: + """basic, new and additional State Pension as policyengine-uk main computed + them before #1941 (6d89338a), with the add-on uprated by the type's full + rate. A reference for the differential test.""" + pension_type = calculate(sim, "state_pension_type", year).astype(str) + over_spa = calculate(sim, "is_SP_age", year).astype(bool) + rates, data_rates = flat_rates(sim, year), flat_rates(sim, DATA_YEAR) + full = by_type(pension_type, data_rates["BASIC"], data_rates["NEW"]) + full_now = by_type(pension_type, rates["BASIC"], rates["NEW"]) + has_rate = full > 0 + safe_full = np.where(has_rate, full, 1) + share = np.where(has_rate, np.minimum(weekly, full) / safe_full, 0) + basic = np.where(pension_type == "BASIC", share * rates["BASIC"], 0) + new = np.where(pension_type == "NEW", share * rates["NEW"], 0) + above = np.where(pension_type != "NONE", np.maximum(weekly - full, 0), 0) + additional = over_spa * above * np.where(has_rate, full_now / safe_full, 1) + return tuple(WEEKS_IN_YEAR * x for x in (basic, new, additional)) + + +# The model computes in float32 and the reference in float64. +FLOAT32 = dict(rtol=1e-6, atol=0.01) + + +def paired(people: list, extra: list) -> list: + """Each person, then the same person reporting ``extra`` more a week.""" + return people + [ + {**person, "weekly": person["weekly"] + more} + for person, more in zip(people, extra) + ] + + +@PROPERTY_SETTINGS +@given(people=PEOPLE, data=st.data()) +def test_add_on_follows_september_cpi_and_nothing_else_changes(people, data): + """For every record and year from 2024-25 to 2030-31: + + - the add-on is never negative, and never falls as the reported amount rises; + - from one year to the next, with the type unchanged, it grows by that + year's September CPI rise; + - differential against main: the flat-rate parts are unchanged, the add-on + is unchanged in the data year, and otherwise differs only by uprating with + September CPI instead of the full rate.""" + extra = data.draw( + st.lists( + st.floats(min_value=0, max_value=300), + min_size=len(people), + max_size=len(people), + ) + ) + records = paired(people, extra) + sim = projected_simulation(records) + weekly = np.array([p["weekly"] for p in records]) + n = len(people) + previous = None + for year in YEARS: + pension_type = calculate(sim, "state_pension_type", year).astype(str) + basic, new, additional = (calculate(sim, v, year) for v in COMPONENTS) + assert np.all(additional >= 0) + assert np.all(additional[n:] >= additional[:n] - 1e-9) + main_basic, main_new, main_additional = main_components(sim, weekly, year) + assert np.allclose(basic, main_basic, **FLOAT32) + assert np.allclose(new, main_new, **FLOAT32) + if year == DATA_YEAR: + assert np.allclose(additional, main_additional, **FLOAT32) + flat_up = by_type( + pension_type, + flat_uprating(sim, year, "BASIC"), + flat_uprating(sim, year, "NEW"), + ) + flat_up = np.where(pension_type == "NONE", 1, flat_up) + assert np.allclose( + additional * flat_up, + main_additional * add_on_uprating(sim, year), + rtol=INDEX_ROUNDING, + atol=0.01, + ) + if previous is not None: + previous_type, previous_additional = previous + same = (pension_type == previous_type) & (previous_additional > 0) + rise = 1 + add_on_rise(sim, year) + assert np.allclose( + additional[same], + previous_additional[same] * rise, + rtol=INDEX_ROUNDING, + atol=1e-6, + ) + previous = (pension_type, additional) + + +RATE = st.floats(min_value=0, max_value=500) + + +@PROPERTY_SETTINGS +@given( + people=PEOPLE, + basic_rate=RATE, + new_rate=RATE, + from_year=st.sampled_from([None, 2025, 2027]), +) +def test_full_rate_reforms_never_move_the_add_on( + people, basic_rate, new_rate, from_year +): + """Differential: the same records with and without a reform to the full + rates, undated (so also in the data year) or from a later year. In law the + add-on rises with prices whatever the full rates do, and the reported amount + is still split at the legislated data-year rates, so the add-on is the same + in every year.""" + period = ( + "2000-01-01.2100-12-31" + if from_year is None + else f"{from_year}-01-01.2100-12-31" + ) + reform = {BSP: {period: basic_rate}, NSP: {period: new_rate}} + baseline = projected_simulation(people) + reformed = Microsimulation(dataset=year_dataset(people, DATA_YEAR), reform=reform) + for year in YEARS: + assert np.allclose( + calculate(reformed, "additional_state_pension", year), + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + + # Rate reforms (PolicyEngine/policyengine-uk#2122). The reported amount is # split at the legislated (baseline) data-year rate; the flat-rate part then -# follows the reformed period rate, and the part above the legislated rate is -# scaled by the reformed period rate over the legislated data-year rate. +# follows the reformed period rate. The part above the legislated rate rises +# with September CPI, whatever the full rates (#1941): a reform to the full +# rates no longer scales it. To abolish State Pension altogether, use +# gov.contrib.abolish_state_pension. NSP = "gov.dwp.state_pension.new_state_pension.amount" BSP = "gov.dwp.state_pension.basic_state_pension.amount" @@ -331,12 +678,16 @@ def test_components_add_up_whatever_ages_the_period_carries(people, period, data } -def expected_under_rate(weekly: float, legislated: float, reformed: float) -> dict: +def expected_under_rate( + weekly: float, legislated: float, reformed: float, add_on_up: float = 1 +) -> dict: """Annual flat-rate and add-on amounts for a record reporting ``weekly`` in a data year whose legislated full rate is ``legislated``, in a period whose - reformed full rate is ``reformed``.""" + reformed full rate is ``reformed`` and whose September CPI is ``add_on_up`` + times the data year's (1 without a dataset, where the data year is the + period).""" share = min(weekly, legislated) / legislated - add_on = max(weekly - legislated, 0) * reformed / legislated + add_on = max(weekly - legislated, 0) * add_on_up return { "flat": share * reformed * WEEKS_IN_YEAR, "additional": add_on * WEEKS_IN_YEAR, @@ -398,8 +749,8 @@ def test_situation_basic_state_pension_follows_a_dated_rate_reform(): def test_situation_protected_payment_stays_additional_under_a_rate_reform(): """£10 a week above the legislated full rate is a protected payment. The - flat-rate part follows the reform; the protected payment is scaled by the - reformed over the legislated rate, as add-ons are (see #1941).""" + flat-rate part follows the reform; the protected payment stays £10 a week, + as it rises with prices, not with the full rate (SI 2026/148 art 6(3)(b)).""" year = 2026 legislated = LEGISLATED[(year, "NEW")] weekly = legislated + 10 @@ -424,19 +775,26 @@ def test_situation_protected_payment_stays_additional_under_a_rate_reform(): ({"age": 90, "male": True, "months": 3, "weekly": 250}, "BASIC", BSP), ], ) -def test_undated_rate_reform_moves_state_pension_with_the_rate( +def test_undated_rate_reform_moves_only_the_flat_rate_part( person, pension_type, parameter, reformed ): """An undated reform rewrites the data year's rate too. The record is still - split at the legislated 2024-25 rate, so State Pension rises with a higher - rate, falls with a lower one and is nil at a rate of zero.""" + split at the legislated 2024-25 rate, so the flat-rate part rises with a + higher rate, falls with a lower one and is nil at a rate of zero, while the + add-on keeps rising with September CPI: at a rate of zero the record keeps + its additional pension or protected payment. This is a deliberate change + from before #1941, when the add-on was scaled with the full rate and a rate + of zero removed all State Pension.""" sim = Microsimulation( dataset=year_dataset([person], DATA_YEAR), reform={parameter: reformed} ) year = 2027 assert calculate(sim, "state_pension_type", year)[0] == pension_type expected = expected_under_rate( - person["weekly"], LEGISLATED[(DATA_YEAR, pension_type)], reformed + person["weekly"], + LEGISLATED[(DATA_YEAR, pension_type)], + reformed, + add_on_uprating(sim, year), ) flat_variable = ( "new_state_pension" if pension_type == "NEW" else "basic_state_pension" @@ -444,12 +802,27 @@ def test_undated_rate_reform_moves_state_pension_with_the_rate( assert calculate(sim, flat_variable, year)[0] == pytest.approx( expected["flat"], abs=0.01 ) - assert calculate(sim, "additional_state_pension", year)[0] == pytest.approx( - expected["additional"], abs=0.01 + assert calculate(sim, "additional_state_pension", year)[0] == approx( + expected["additional"] ) - assert calculate(sim, "state_pension", year)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + assert calculate(sim, "state_pension", year)[0] == approx( + expected["flat"] + expected["additional"] + ) + + +def test_abolishing_state_pension_still_removes_the_add_on(): + """gov.contrib.abolish_state_pension removes the whole State Pension, + additional pension and protected payments included.""" + people = [ + {"age": 70, "male": True, "months": 3, "weekly": 240}, + {"age": 90, "male": True, "months": 3, "weekly": 250}, + ] + sim = Microsimulation( + dataset=year_dataset(people, DATA_YEAR), + reform={"gov.contrib.abolish_state_pension": True}, ) + for year in (DATA_YEAR, 2027, 2030): + assert np.all(calculate(sim, "state_pension", year) == 0) def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): @@ -460,7 +833,9 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): dataset=year_dataset([person], DATA_YEAR), reform={NSP: {"2026-01-01.2100-12-31": 300.0}}, ) - expected = expected_under_rate(240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0) - assert calculate(sim, "state_pension", 2027)[0] == pytest.approx( - expected["flat"] + expected["additional"], abs=0.01 + expected = expected_under_rate( + 240, LEGISLATED[(DATA_YEAR, "NEW")], 300.0, add_on_uprating(sim, 2027) + ) + assert calculate(sim, "state_pension", 2027)[0] == approx( + expected["flat"] + expected["additional"] ) diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 95cbe06ea7..5125b98dd2 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -146,9 +146,9 @@ def calculate(sim, year, order=OUTPUTS): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML cases, run on every invocation: random draws seldom raise CTC or -# hit the third row. Pension Credit earnings are net of SPC Regs Sch VI -# para 5; the CTC family element is entered at its statutory value (see the -# YAML file). +# hit the third row. Employment income is gross; the model applies SPC Regs +# Sch VI para 5 once. The CTC family element is entered at its statutory +# value (see the YAML file). _PENSIONER = { "age": 70, "employment_income": 2_600, @@ -157,21 +157,27 @@ def calculate(sim, year, order=OUTPUTS): "working_tax_credit_reported": 1, } YAML_FAMILIES = [ - # Guarantee Credit with WTC: passported. + # Gross wages 2,600 - single disregard 5 x 52 = 2,340. With passported + # WTC 2,435, PC is 218.15 x 52 - 6,000 - 2,340 - 2,435 = 568.80. ( [{**_PENSIONER, "state_pension": 6_000}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Pension Credit only on the income-tested award: the third row. + # The same gross wages leave 2,340 after Sch VI. Passported WTC gives + # PC 11,343.80 - 6,600 - 2,340 - 2,435 < 0, so the passport does not hold; + # income-tested WTC is 2,435 - (9,200 - 7,955) x 41% = 1,924.55, and + # PC remains 11,343.80 - 6,600 - 2,340 - 1,924.55 = 479.25. ( [{**_PENSIONER, "state_pension": 6_600}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Savings Credit alone, with CTC raised by the passport. + # Gross wages 3,000 - couple disregard 10 x 52 = 2,480; pensions 17,000 + # give PC income 19,480. The 989.976 maximum SC less + # (19,480 - 17,313.40) x 40% gives 123.336, so CTC is passported. ( [ { @@ -185,7 +191,6 @@ def calculate(sim, year, order=OUTPUTS): ], [{"age": 10}], { - "pension_credit_earnings": 2_480, "CTC_family_element": 545, }, {"savings": 10_000}, @@ -202,7 +207,8 @@ def calculate(sim, year, order=OUTPUTS): {"has_mixed_age_couple_pension_credit_saving": False}, {"savings": 10_000}, ), - # The same couple with the saving: Guarantee Credit, passported. + # With the saving, gross wages 2,600 - couple disregard 520 = 2,080. + # PC is 17,313.40 - 6,000 - 2,080 - passported WTC 4,935 = 4,298.40. ( [ {**_PENSIONER, "state_pension": 6_000, "months_since_last_birthday": 9}, @@ -211,7 +217,6 @@ def calculate(sim, year, order=OUTPUTS): [], { "has_mixed_age_couple_pension_credit_saving": True, - "pension_credit_earnings": 2_080, }, {"savings": 10_000}, ), @@ -333,7 +338,9 @@ def test_passport_works_in_traced_and_nested_branch_simulations(): } ], [], - {"pension_credit_earnings": 2_340}, + # Gross wages 2,600 less Sch VI 260 leave 2,340; PC is + # 218.15 x 52 - 6,000 - 2,340 - passported WTC 2,435 = 568.80. + {}, {}, ) for trace in [False, True]: diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 23211c9960..828e3f10e2 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -96,11 +96,11 @@ PATHS = { "personal_allowance": "income_tax/allowances/personal_allowance/amount.yaml", "basic_rate_limit": "income_tax/rates/uk.yaml", - "primary_threshold": "national_insurance/class_1/thresholds/primary_threshold.yaml", - "upper_earnings_limit": "national_insurance/class_1/thresholds/upper_earnings_limit.yaml", - "secondary_threshold": "national_insurance/class_1/thresholds/secondary_threshold.yaml", - "lower_profits_limit": "national_insurance/class_4/thresholds/lower_profits_limit.yaml", - "upper_profits_limit": "national_insurance/class_4/thresholds/upper_profits_limit.yaml", + "primary_threshold": "national_insurance/class_1/employee_scale.yaml", + "upper_earnings_limit": "national_insurance/class_1/employee_scale.yaml", + "secondary_threshold": "national_insurance/class_1/employer_scale.yaml", + "lower_profits_limit": "national_insurance/class_4/contribution_scale.yaml", + "upper_profits_limit": "national_insurance/class_4/contribution_scale.yaml", } @@ -112,19 +112,19 @@ def system(): def threshold_parameters(root): """The seven parameters under a ``gov`` node.""" hmrc = root.hmrc - class_1 = hmrc.national_insurance.class_1.thresholds - class_4 = hmrc.national_insurance.class_4.thresholds + class_1 = hmrc.national_insurance.class_1 + class_4 = hmrc.national_insurance.class_4 return { "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount, # Bracket 1 itself: a scale read at an instant sorts its thresholds, # so its thresholds[1] would be the £125,140 additional rate threshold # once the basic rate limit passed it. "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold, - "primary_threshold": class_1.primary_threshold, - "upper_earnings_limit": class_1.upper_earnings_limit, - "secondary_threshold": class_1.secondary_threshold, - "lower_profits_limit": class_4.lower_profits_limit, - "upper_profits_limit": class_4.upper_profits_limit, + "primary_threshold": class_1.employee_scale.brackets[1].threshold, + "upper_earnings_limit": class_1.employee_scale.brackets[2].threshold, + "secondary_threshold": class_1.employer_scale.brackets[1].threshold, + "lower_profits_limit": class_4.contribution_scale.brackets[1].threshold, + "upper_profits_limit": class_4.contribution_scale.brackets[2].threshold, } @@ -408,6 +408,9 @@ def series(base, first, last, month_day="04-06"): } }, "rates": { + "savings_starter_rate": { + "allowance": series(5_000, 2021, 2030) + }, "uk": { "brackets": [ { @@ -424,24 +427,76 @@ def series(base, first, last, month_day="04-06"): ), }, ] - } + }, }, }, "national_insurance": { "class_1": { - "thresholds": { - name: series(bases[name], 2021, last_years[name]) - for name in ( - "primary_threshold", - "upper_earnings_limit", - "secondary_threshold", - ) - } + "employee_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["primary_threshold"], + 2021, + last_years["primary_threshold"], + ), + "rate": {"values": {"2021-01-01": 0.08}}, + }, + { + "threshold": series( + bases["upper_earnings_limit"], + 2021, + last_years["upper_earnings_limit"], + ), + "rate": {"values": {"2021-01-01": 0.02}}, + }, + ] + }, + "employer_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["secondary_threshold"], + 2021, + last_years["secondary_threshold"], + ), + "rate": {"values": {"2021-01-01": 0.15}}, + }, + ] + }, }, "class_4": { - "thresholds": { - name: series(bases[name], 2021, last_years[name]) - for name in ("lower_profits_limit", "upper_profits_limit") + "contribution_scale": { + "brackets": [ + { + "threshold": {"values": {"2021-01-01": 0}}, + "rate": {"values": {"2021-01-01": 0}}, + }, + { + "threshold": series( + bases["lower_profits_limit"], + 2021, + last_years["lower_profits_limit"], + ), + "rate": {"values": {"2021-01-01": 0.06}}, + }, + { + "threshold": series( + bases["upper_profits_limit"], + 2021, + last_years["upper_profits_limit"], + ), + "rate": {"values": {"2021-01-01": 0.02}}, + }, + ] } }, }, @@ -452,28 +507,9 @@ def series(base, first, last, month_day="04-06"): def synthetic_values(root, year): - hmrc = root.gov.hmrc instant = f"{year}-04-30" return { - "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount( - instant - ), - "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold(instant), - "primary_threshold": hmrc.national_insurance.class_1.thresholds.primary_threshold( - instant - ), - "upper_earnings_limit": hmrc.national_insurance.class_1.thresholds.upper_earnings_limit( - instant - ), - "secondary_threshold": hmrc.national_insurance.class_1.thresholds.secondary_threshold( - instant - ), - "lower_profits_limit": hmrc.national_insurance.class_4.thresholds.lower_profits_limit( - instant - ), - "upper_profits_limit": hmrc.national_insurance.class_4.thresholds.upper_profits_limit( - instant - ), + name: leaf(instant) for name, leaf in threshold_parameters(root.gov).items() } @@ -704,6 +740,21 @@ def test_indexed_parameters_carry_no_uprating(name): data = yaml.safe_load((PARAMETER_DIR / "gov/hmrc" / PATHS[name]).read_text()) if name == "basic_rate_limit": metadata = data["brackets"][1]["threshold"]["metadata"] + elif name in [ + "primary_threshold", + "upper_earnings_limit", + "secondary_threshold", + "lower_profits_limit", + "upper_profits_limit", + ]: + index = { + "primary_threshold": 1, + "upper_earnings_limit": 2, + "secondary_threshold": 1, + "lower_profits_limit": 1, + "upper_profits_limit": 2, + }[name] + metadata = data["brackets"][index]["threshold"]["metadata"] else: metadata = data["metadata"] assert "uprating" not in metadata @@ -779,3 +830,23 @@ def test_income_tax_and_nics_use_the_indexed_thresholds_in_2031(system): assert primary[i] == pytest.approx(expected_primary, abs=0.02), income assert additional[i] == pytest.approx(expected_additional, abs=0.02), income assert np.all(np.diff(income_tax) >= 0) + + +def test_canonical_threshold_registration_keeps_incoming_savings_writer(): + """The merged indexation owns eight unique paths, including annual savings.""" + from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + INDEXED_THRESHOLDS, + ) + + expected = { + "gov.hmrc.income_tax.allowances.personal_allowance.amount", + "gov.hmrc.income_tax.rates.uk[1].threshold", + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", + "gov.hmrc.national_insurance.class_1.employee_scale[1].threshold", + "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold", + "gov.hmrc.national_insurance.class_1.employer_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[1].threshold", + "gov.hmrc.national_insurance.class_4.contribution_scale[2].threshold", + } + assert len(INDEXED_THRESHOLDS) == len(expected) + assert set(INDEXED_THRESHOLDS) == expected diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 85b09326d0..2b0c05f5b3 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -29,7 +29,11 @@ award equal the same family with those profits cut by the loss. The model's means tests use annual tax deductions, so otherwise the loss reaches them through those deductions. UC's own deductions for tax and - NI on earnings are held alongside Income Tax and NI. + NI on earnings and Pension Credit's tax attribution to employed and + self-employed earnings are held alongside Income Tax and NI, including + the actual Class 2/4 components replaced by notional PC deductions. + The personal reliefs used for PC's notional tax are held too: the loss + can change entitlement to those reliefs without offsetting PC earnings. 5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses (not a child's) come off their applicable income, never below zero, and the award never falls. @@ -610,6 +614,138 @@ def values(sim, variable): @PROPERTY_SETTINGS @example(PINNED_FAMILIES) +@example( + [ + ( + [ + adult(age=57, trading_loss=2_399.8265339892096, hours_worked=0.0), + adult( + age=18, + employment_income=1.0, + self_employment_income=24_100.0, + savings_interest_income=1.195, + trading_loss=100.0, + hours_worked=0.0, + ), + {"age": 4, "trading_loss": 1.9}, + {"age": 11, "trading_loss": 0.0}, + {"age": 13, "trading_loss": 4_999.999999999999}, + ], + { + "rent": 0.0, + "council_tax": 905.6960743779131, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "SCOTLAND", + }, + ), + ( + [ + adult( + age=62, + employment_income=48_163.774854368, + private_pension_income=18.8, + savings_interest_income=3_726.67143339902, + hours_worked=0.0, + ), + adult( + age=45, + employment_income=35_394.709560981835, + self_employment_income=24_566.859819474743, + savings_interest_income=2_950.8270749404533, + trading_loss=33_435.74658513417, + hours_worked=16.0, + ), + {"age": 11, "trading_loss": 3_414.540906596419}, + {"age": 7, "trading_loss": 0.0}, + {"age": 3, "trading_loss": 0.0}, + ], + { + "rent": 8_205.896001170435, + "council_tax": 2_894.867267754779, + "tenure_type": "RENT_PRIVATELY", + "region": "NORTH_EAST", + }, + ), + ( + [ + adult( + age=55, + self_employment_income=22_244.933680665035, + private_pension_income=15_657.59819004124, + trading_loss=20_700.0, + ), + {"age": 8, "trading_loss": 2_806.337586256281}, + ], + { + "rent": 0.0, + "council_tax": 2_169.4036386087373, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "WALES", + }, + ), + ( + [ + adult( + age=23, + employment_income=1.1, + self_employment_income=16_997.525784723435, + trading_loss=27_557.714012466255, + hours_worked=16.0, + ) + ], + { + "rent": 0.0, + "council_tax": 1_748.2542780681022, + "tenure_type": "RENT_PRIVATELY", + "region": "WALES", + }, + ), + # CI's fifth family: the partner's loss enables a £1,260 Marriage + # Allowance. Removing it changes PC notional tax by £252 unless + # the statutory personal reliefs are held with the other tax inputs. + ( + [ + adult( + age=57, + self_employment_income=26_702.388163483578, + private_pension_income=7_714.38595373581, + savings_interest_income=4_509.347498481971, + hours_worked=16.0, + ), + adult( + age=31, + private_pension_income=17_970.97909886547, + trading_loss=10_351.972522786527, + ), + {"age": 11, "trading_loss": 0.0}, + ], + { + "rent": 374.4475438112428, + "council_tax": 2_056.072240654743, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "NORTH_EAST", + }, + ), + ] +) +@example( + [ + ( + [ + adult(age=18, self_employment_income=17_809.094330166412), + {"age": 0, "trading_loss": 3_692.5341857798658}, + ], + home(1.0, "NORTH_EAST"), + ), + # Actual Class 4 falls to zero with this loss, while CER deductions + # still use the separate positive employment's 30,000 profit. Holding + # total NI also requires holding its actual Class 4 component. + ( + [adult(self_employment_income=30_000, trading_loss=20_000)], + home(0.0), + ), + ] +) @given(st.lists(families(), min_size=4, max_size=10)) def test_means_tests_see_the_loss_only_through_tax(units): with_loss = Simulation(situation=family_situation(units)) @@ -622,10 +758,37 @@ def values(sim, variable): deductions = [ "income_tax", "national_insurance", + # PC replaces these actual components with CER notional deductions; + # hold them consistently with the total national_insurance input. + "ni_class_2", + "ni_class_4", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", + # CER 13 uses these personal reliefs for PC's notional tax. A loss + # can alter them (for example by enabling a partner's Marriage + # Allowance transfer), so preserve their with-loss values as well. + "personal_allowance", + "blind_persons_allowance", + "marriage_allowance", + "married_couples_allowance_deduction", ] tax = {v: values(with_loss, v) for v in deductions} + # SPC Regs reg 17(11) substitutes CER notional deductions for the tax + # actually attributable to profits. Holding income_tax alone also holds + # it in the no-profit counterfactual, incorrectly setting that attribution + # to zero. Preserve both PC tax allocations from the with-loss side. + # In the child-loss example the adult has no loss: actual/notional tax + # is (17,809.09433 - 12,570) x 20% = 1,047.818866, and notional Class 4 + # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives + # PC income 17,809.09433 - 1,047.818866 - 314.345660 - 1,040 = 15,406.93 + # on both sides; the child's loss never enters the claimant's earnings. + pc_tax = { + v: values(with_loss, v) + for v in [ + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + ] + } loss = values(with_loss, "trading_loss") profits = values(with_loss, "self_employment_income") mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) @@ -644,7 +807,13 @@ def held(k, **extra): situation=family_situation( units, {name: held(k) for k, name in enumerate(names)}, - {b: {"universal_credit": float(uc[k])} for k, b in enumerate(benunits)}, + { + b: { + "universal_credit": float(uc[k]), + **{v: float(pc_tax[v][k]) for v in pc_tax}, + } + for k, b in enumerate(benunits) + }, ) ) for variable in MEANS_TESTED: diff --git a/policyengine_uk/tests/test_uc_employment_expenses.py b/policyengine_uk/tests/test_uc_employment_expenses.py new file mode 100644 index 0000000000..f27c0cb397 --- /dev/null +++ b/policyengine_uk/tests/test_uc_employment_expenses.py @@ -0,0 +1,320 @@ +"""UC expense disregards use already-allowable amounts, not raw-cost claims.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEAR = 2024 + + +def situation(people=None, *, year=YEAR, benunit=None): + people = people or {"claimant": {"age": 30, "employment_income": 6_000}} + names = list(people) + return { + "people": { + name: {key: {year: value} for key, value in inputs.items()} + for name, inputs in people.items() + }, + "benunits": { + "family": { + "members": names, + "would_claim_uc": {year: True}, + **{key: {year: value} for key, value in (benunit or {}).items()}, + } + }, + "households": { + "household": { + "members": names, + "region": {year: "LONDON"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + }, + } + + +def simulation(people=None, *, year=YEAR, benunit=None): + return Simulation(situation=situation(people, year=year, benunit=benunit)) + + +def test_allowable_expenses_increase_natural_final_uc_cash(): + ordinary = simulation() + expenses = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + for sim, earned in [(ordinary, 6_000), (expenses, 5_400)]: + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [earned], + atol=0.01, + ) + assert sim.calculate("is_uc_eligible", YEAR)[0] + assert sim.calculate("claims_universal_credit", YEAR)[0] + assert not sim.calculate("uc_mif_applies", YEAR)[0] + for variable in [ + "income_tax", + "national_insurance", + "pension_contributions", + "uc_work_allowance", + "uc_benefit_cap_reduction", + "uc_deductions", + ]: + np.testing.assert_allclose(sim.calculate(variable, YEAR), [0], atol=0.01) + # Both awards are positive and below the unchanged maximum: the + # 55% earnings taper passes on 600 * .55 = 330, without clipping. + for variable in ["universal_credit_pre_benefit_cap", "universal_credit"]: + low = ordinary.calculate(variable, YEAR) + high = expenses.calculate(variable, YEAR) + assert low[0] > 0 + assert high[0] < expenses.calculate("uc_maximum_amount", YEAR)[0] + np.testing.assert_allclose(high - low, [330], atol=0.01) + + +@pytest.mark.parametrize("year", [2020, 2024, 2026]) +def test_zero_expenses_preserve_the_default(year): + default = simulation(year=year) + zero = simulation( + {"claimant": {"age": 30, "employment_income": 6_000, "employment_expenses": 0}}, + year=year, + ) + for variable in [ + "uc_individual_earned_income_before_mif", + "uc_earned_income", + "universal_credit", + ]: + np.testing.assert_array_equal( + default.calculate(variable, year), zero.calculate(variable, year) + ) + + +@pytest.mark.parametrize("expenses, expected", [(600, 5_400), (6_000, 0)]) +def test_allowed_deduction_and_exact_earnings_limit(expenses, expected): + # The supplied amount has already satisfied per-employment legal limits. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expenses, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [expected], atol=0.01 + ) + + +def test_each_claimants_deduction_is_used_once(): + sim = simulation( + { + "claimant": { + "age": 40, + "employment_income": 6_000, + "employment_expenses": 600, + }, + "partner": { + "age": 38, + "employment_income": 4_000, + "employment_expenses": 400, + }, + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400, 3_600], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), [9_000], atol=0.01 + ) + + +def test_nonclaimants_expense_does_not_reduce_claimants_earnings(): + sim = simulation( + { + "parent": {"age": 40, "is_parent": True, "employment_income": 10_000}, + "child": { + "age": 17, + "current_education": "UPPER_SECONDARY", + "employment_income": 3_000, + "employment_expenses": 600, + }, + } + ) + np.testing.assert_array_equal( + sim.calculate("is_uc_assessed_claimant", YEAR), [True, False] + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [10_000, 2_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), + 10_000 - sim.calculate("uc_work_allowance", YEAR), + atol=0.01, + ) + + +@pytest.mark.parametrize("trade, expected", [(2_000, 7_400), (-2_000, 5_400)]) +def test_trade_earnings_keep_their_separate_nil_floor(trade, expected): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "self_employment_income": trade, + "uc_is_in_startup_period": True, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + + +def test_mif_still_compares_with_expense_net_actual_earnings(): + # Supplied MIF statuses/threshold are composition controls, not a + # natural claim that every worker is subject to the floor. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "uc_mif_applies": True, + "uc_minimum_income_floor": 8_000, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income", YEAR), [8_000], atol=0.01 + ) + + +def test_annual_flow_is_allocated_to_month_without_changing_input(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + np.testing.assert_allclose( + sim.calculate("employment_expenses", "2024-04"), [50], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", "2024-04"), + [450], + atol=0.01, + ) + + +def test_direct_final_uc_input_remains_an_override(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + }, + benunit={"universal_credit": 1_234}, + ) + np.testing.assert_allclose( + sim.calculate("universal_credit", YEAR), [1_234], atol=0.01 + ) + + +def test_multiunit_vector_keeps_expenses_with_their_owner(): + units = [ + situation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expense, + } + } + ) + for expense in [0, 600] + ] + merged = {entity: {} for entity in ["people", "benunits", "households"]} + for index, unit in enumerate(units): + for entity, rows in unit.items(): + for name, row in rows.items(): + row = dict(row) + if "members" in row: + row["members"] = [f"{member}_{index}" for member in row["members"]] + merged[entity][f"{name}_{index}"] = row + sim = Simulation(situation=merged) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_000, 5_400], + atol=0.01, + ) + + +def test_statutory_pay_and_pension_components_are_retained(): + # The pension and statutory-pay inputs exercise existing composition, + # with qualified expenses below the worker's employment earnings. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "statutory_sick_pay": 1_000, + "pension_contributions": 100, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_300], + atol=0.01, + ) + + +def test_only_the_specific_expense_input_is_disregarded(): + # Supplied aggregate override: this checks consumer routing, not + # statutory admissibility of a separate class of deduction. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "employment_deductions": 2_500, + } + } + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [3_500], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) diff --git a/policyengine_uk/tests/test_uc_rebalancing.py b/policyengine_uk/tests/test_uc_rebalancing.py index 5340e91e53..25ce75d108 100644 --- a/policyengine_uk/tests/test_uc_rebalancing.py +++ b/policyengine_uk/tests/test_uc_rebalancing.py @@ -408,3 +408,290 @@ def test_a_branch_can_state_its_own_status(): _new_claimant_rate(sim, 2026) ) assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(parent) + + +# The inactive health-component counterfactual starts from pre-Act 2025 law, +# independently of the new/protected claimant split (issue #2283). +# The independently sourced 2026 CPI-only monthly-penny anchor is £439.35. +def _inactive_lcwra_expected(sim, year): + parameters = sim.tax_benefit_system.parameters + + def index(y): + return float(parameters(str(y)).gov.benefit_uprating_cpi) + + return 439.35 * index(year) / index(2026) * 12 + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_inactive_health_element_restores_pre_act_uprated_amount(year): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", year)[0] + # Nonzero verifies that this is the explicit-year claimant, not the + # nondiagnostic scalar-input/default-2025 situation from preflight. + assert actual > 0 + assert actual == pytest.approx(_inactive_lcwra_expected(sim, year), rel=0, abs=1e-3) + + +@pytest.mark.parametrize("new_claimant", [False, True]) +def test_inactive_health_amount_is_independent_of_claimant_cohort(new_claimant): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_receives_new_claimant_health_element"] = { + year: new_claimant for year in YEARS + } + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + _inactive_lcwra_expected(sim, 2026), rel=0, abs=1e-3 + ) + + +def test_mixed_toggle_uses_independent_inactive_years_and_retains_active_path(): + baseline = Simulation(situation=_uc_claimant(30)) + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": { + "2026": False, + "2027": True, + "2028": False, + }, + }, + applied_before_data_load=True, + ), + ) + for year in [2026, 2028]: + assert sim.calculate("uc_LCWRA_element", year)[0] == pytest.approx( + _inactive_lcwra_expected(sim, year), rel=0, abs=1e-3 + ) + assert sim.calculate("uc_LCWRA_element", 2027)[0] == pytest.approx( + baseline.calculate("uc_LCWRA_element", 2027)[0], rel=0, abs=1e-3 + ) + + +@pytest.mark.parametrize("route", ["scenario", "legacy_reform"]) +def test_inactive_health_element_keeps_explicit_parameter_amount(route): + changes = { + "gov.dwp.universal_credit.rebalancing.active": False, + "gov.dwp.universal_credit.elements.disabled.amount": {"2026": 300}, + } + kwargs = { + "scenario": Scenario(parameter_changes=changes, applied_before_data_load=True) + } + if route == "legacy_reform": + kwargs = { + "reform": { + "gov.dwp.universal_credit.rebalancing.active": {"2026": False}, + "gov.dwp.universal_credit.elements.disabled.amount": { + "2026-01-01": 300 + }, + } + } + sim = Simulation(situation=_uc_claimant(30), **kwargs) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +def test_inactive_health_element_keeps_directly_supplied_award(): + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_LCWRA_element"] = {"2026": 7_200} + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == 7_200 + + +def test_inactive_health_element_reaches_final_uc_without_forced_intermediates(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # No earnings, other benefits, housing or children: the standard allowance + # plus the independent inactive health amount is the whole UC award. + expected = STANDARD_ALLOWANCE_2026["SINGLE_OLD"] * 12 + _inactive_lcwra_expected( + sim, 2026 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_modifier_is_idempotent(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + expected = _inactive_lcwra_expected(sim, 2026) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + expected, rel=0, abs=1e-3 + ) + + +def test_inactive_health_amount_reform_on_a_built_simulation_keeps_precedence(): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + uc_reform.add_universal_credit_reform(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx(300 * 12) + + +@pytest.mark.parametrize("repeat_modifier", [False, True]) +def test_inactive_cached_awards_follow_public_late_amount_reform(repeat_modifier): + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + # Prime both caches before using the public parameter-change lifecycle. + assert sim.calculate("uc_LCWRA_element", 2026)[0] > 3_600 + assert sim.calculate("universal_credit", 2026)[0] > 8_698.80 + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + if repeat_modifier: + uc_reform.add_universal_credit_reform(sim) + # The 2026 single-25+ standard allowance is £424.90/month (SI 2026/113), + # plus the explicitly reformed £300/month LCWRA amount, with no reductions. + assert [ + sim.calculate("uc_LCWRA_element", 2026)[0], + sim.calculate("universal_credit", 2026)[0], + ] == pytest.approx([3_600, 8_698.80], rel=0, abs=0.01) + + +def test_inactive_2026_uses_penny_rounded_september_cpi_anchor(): + # Declared CPI-only counterfactual: DWP 2025 LCWRA £423.27, September + # 2025 CPI 3.8%; round the monthly £439.35426 to £439.35, then annualise. + # SSAA 1992 s.150 permits discretion; this is not an enacted LCWRA rate. + sim = Simulation( + situation=_uc_claimant(30), + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + }, + applied_before_data_load=True, + ), + ) + actual = sim.calculate("uc_LCWRA_element", 2026)[0] + assert actual > 0 + assert actual == pytest.approx(5_272.20, rel=0, abs=0.01) + assert actual / 12 == pytest.approx(439.35, rel=0, abs=0.001) + # Existing legislated SA stays separate: single25+ £424.90/month. + assert sim.calculate("uc_standard_allowance", 2026)[0] == pytest.approx( + 424.90 * 12, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 10_371.00, rel=0, abs=0.01 + ) + + +@pytest.mark.parametrize("protected_floor", [0, 0.85]) +def test_inactive_health_cash_reconciles_with_currentmain_deductions(protected_floor): + # Natural LCWRA circumstances make this claimant benefit-cap exempt. + # The 15% deduction rate is an explicit interface input, not imputed + # debt incidence. A protected floor is a separate modeled reform. + situation = _uc_claimant(30) + situation["benunits"]["benunit"]["uc_deduction_rate"] = {2026: 0.15} + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dwp.universal_credit.rebalancing.active": False, + "gov.dwp.universal_credit.deductions.protected_floor": protected_floor, + }, + applied_before_data_load=True, + ), + ) + # Declared CPI-only monthly-penny counterfactual, not an enacted rate: + # LCWRA 5272.20 + legislated SA 5098.80 = 10371; debt is 764.82. + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + 5_272.20, rel=0, abs=0.01 + ) + assert sim.calculate("uc_standard_allowance", 2026)[0] == pytest.approx( + 5_098.80, rel=0, abs=0.01 + ) + assert sim.calculate("uc_benefit_cap_reduction", 2026)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] == 0 + assert sim.calculate("universal_credit_pre_benefit_cap", 2026)[0] == pytest.approx( + 10_371.00, rel=0, abs=0.01 + ) + assert sim.calculate("uc_deductions", 2026)[0] == pytest.approx( + 764.82, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 9_606.18, rel=0, abs=0.01 + ) + assert ( + sim.calculate("uc_deductions", 2026)[0] + + sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] + ) == pytest.approx( + sim.calculate("universal_credit_pre_benefit_cap", 2026)[0] + - sim.calculate("universal_credit", 2026)[0], + rel=0, + abs=0.01, + ) + # Both caches are now primed. The public late amount reform retains + # precedence while the new deduction components still reconcile. + Scenario( + parameter_changes={ + "gov.dwp.universal_credit.elements.disabled.amount": 300, + } + ).apply(sim) + assert sim.calculate("uc_LCWRA_element", 2026)[0] == pytest.approx( + 3_600, rel=0, abs=0.01 + ) + assert sim.calculate("uc_deductions", 2026)[0] == pytest.approx( + 764.82, rel=0, abs=0.01 + ) + assert sim.calculate("universal_credit", 2026)[0] == pytest.approx( + 7_933.98, rel=0, abs=0.01 + ) + assert sim.calculate("uc_benefit_cap_reduction", 2026)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction_after_floor", 2026)[0] == 0 diff --git a/policyengine_uk/tests/test_uc_single_claim_properties.py b/policyengine_uk/tests/test_uc_single_claim_properties.py index f7dbea924e..83b0fbe164 100644 --- a/policyengine_uk/tests/test_uc_single_claim_properties.py +++ b/policyengine_uk/tests/test_uc_single_claim_properties.py @@ -118,6 +118,7 @@ "pip_dl": 0.0, "afcs": 0.0, "esa_contrib": 0.0, + "armed_forces_independence_payment": 0.0, } @@ -132,6 +133,7 @@ def adult(draw, ages): pip_dl=draw(pip), afcs=draw(sometimes_paid), esa_contrib=draw(sometimes_paid), + armed_forces_independence_payment=draw(sometimes_paid), ) @@ -160,6 +162,7 @@ def adult_inputs(age, **inputs): pip_dl=0.0, afcs=0.0, esa_contrib=0.0, + armed_forces_independence_payment=0.0, ) values.update(inputs) return values @@ -259,6 +262,16 @@ def example_families(): tenure="RENT_FROM_COUNCIL", region="NORTH_EAST", ), + # An other member with armed forces independence payment and nothing + # else. + dict( + claimant=adult_inputs(40), + partner=adult_inputs(40, armed_forces_independence_payment=5_000.0), + children=[], + rent=0.0, + tenure="RENT_FROM_COUNCIL", + region="NORTH_EAST", + ), ] @@ -272,6 +285,7 @@ def example_families(): afcs_partner=6, esa_partner=7, afcs_claimant=8, + afip_partner=9, ) EXAMPLE_FAMILIES = example_families() @@ -497,12 +511,12 @@ def test_deduction_is_the_joint_claimants(families, year): @given( families=populations, year=st.sampled_from([2021, 2026]), - balances=st.lists(st.floats(0, 30_000), min_size=18, max_size=18), + balances=st.lists(st.floats(0, 30_000), min_size=20, max_size=20), ) @example( families=example_families(), year=2026, - balances=[8_000.0, 16_000.0, 0.0, 30_000.0, 2_000.0, 0.0] + [5_000.0] * 12, + balances=[8_000.0, 16_000.0, 0.0, 30_000.0, 2_000.0, 0.0] + [5_000.0] * 14, ) def test_capital_includes_the_other_members(families, year, balances): families = copy.deepcopy(families) @@ -700,6 +714,13 @@ def test_examples_reach_the_cases(): assert joint["is_uc_benefit_cap_exempt_specified_benefit"][family] assert single["is_housing_benefit_benefit_cap_exempt_specified_benefit"][family] assert single["is_uc_benefit_cap_exempt_specified_benefit"][i["afcs_claimant"]] + # Reg. 83(1)(c) with reg. 2: armed forces independence payment is + # attendance allowance, which lifts the UC cap only for a claimant. HB + # reg. 75F(1)(ea): the partner's lifts the HB cap either way. + family = i["afip_partner"] + assert not single["is_uc_benefit_cap_exempt_specified_benefit"][family] + assert joint["is_uc_benefit_cap_exempt_specified_benefit"][family] + assert single["is_housing_benefit_benefit_cap_exempt_specified_benefit"][family] # Reg. 80A(2) and Sch 4 para 28(2): single rate and shared accommodation. # HB regs. 2(1), 13D(2) and 75CA(2): a couple either way. family = i["young_renter"] diff --git a/policyengine_uk/tests/test_unused_annual_allowance_projection.py b/policyengine_uk/tests/test_unused_annual_allowance_projection.py new file mode 100644 index 0000000000..dd8b78c249 --- /dev/null +++ b/policyengine_uk/tests/test_unused_annual_allowance_projection.py @@ -0,0 +1,71 @@ +"""A stored unused annual allowance is for its own year only (#2523). + +Unused allowance carried forward (Finance Act 2004 s. 228A) comes from the +three tax years before the one it is for, a window that moves each year. So a +dataset's stored value stays in its own year, and each projected year uses the +variable's estimate instead of a copy. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + +STORED = 50_000.0 + + +def _dataset(stored=None): + ids = np.arange(1, 2) + person = { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 45, + "employment_income": 100_000.0, + "employer_pension_contributions": 70_000.0, + } + if stored is not None: + person["unused_pension_annual_allowance"] = stored + return UKSingleYearDataset( + person=pd.DataFrame(person), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=2024, + ) + + +def test_projected_years_do_not_copy_a_stored_unused_allowance(): + extended = extend_single_year_dataset(_dataset(STORED), parameters, end_year=2027) + + assert extended[2024].person["unused_pension_annual_allowance"].tolist() == [STORED] + for year in (2025, 2026, 2027): + assert "unused_pension_annual_allowance" not in extended[year].person.columns + + +def test_a_projected_year_uses_the_estimate_not_the_stored_value(): + stored = Microsimulation(dataset=_dataset(STORED)) + estimated = Microsimulation(dataset=_dataset()) + + assert stored.calculate("unused_pension_annual_allowance", 2024).values == ( + pytest.approx([STORED]) + ) + # Saving 70,000 a year, grown with pay, exceeds each earlier allowance, so + # the estimate for 2026 carries nothing forward; a copy would give 50,000. + assert stored.calculate( + "unused_pension_annual_allowance", 2026 + ).values == pytest.approx( + estimated.calculate("unused_pension_annual_allowance", 2026).values + ) + assert stored.calculate("unused_pension_annual_allowance", 2026).values[0] == 0 diff --git a/policyengine_uk/tests/test_uprating_inputs_reach_data.py b/policyengine_uk/tests/test_uprating_inputs_reach_data.py index 9f7143e2be..d92df6c5af 100644 --- a/policyengine_uk/tests/test_uprating_inputs_reach_data.py +++ b/policyengine_uk/tests/test_uprating_inputs_reach_data.py @@ -104,6 +104,8 @@ def mock_dataset_with_every_uprated_input() -> UKSingleYearDataset: uprating = economic_assumptions._uprating_index_map() names = set(economic_assumptions._unmapped_uprating_indices(uprating)) names |= {name for variables in uprating.values() for name in variables} + # Uprated by country rather than through the index map (#2501). + names |= economic_assumptions.COUNTRY_UPRATED_INPUTS for name in sorted(names): variable = system.variables.get(name) if variable is None or variable.value_type is not float: diff --git a/policyengine_uk/tests/test_water_bills_growth.py b/policyengine_uk/tests/test_water_bills_growth.py new file mode 100644 index 0000000000..3cf6021b67 --- /dev/null +++ b/policyengine_uk/tests/test_water_bills_growth.py @@ -0,0 +1,25 @@ +"""The published Ofwat years of the water bills series match their sources. + +2025-26: Ofwat, "Ofwat responds to water sector average bill figures" +(29 January 2026) reports a 27% increase in 2025-26, replacing the 26% +forecast in its 30 January 2025 statement. +2026-27: Water UK (28 January 2026) puts the national average bill at GBP 639, +up GBP 33 or 5.4%. +""" + +import pytest + +from policyengine_uk.system import system + +WATER_BILLS = system.parameters.gov.economic_assumptions.yoy_growth.ofwat.water_bills +WATER_INDEX = system.parameters.gov.economic_assumptions.indices.ofwat.water_bills + + +@pytest.mark.parametrize("year, growth", [(2025, 0.27), (2026, 0.054)]) +def test_published_water_bill_growth(year, growth): + assert WATER_BILLS(f"{year}-01-01") == pytest.approx(growth) + + +def test_water_index_compounds_the_published_years(): + ratio = WATER_INDEX("2026-01-01") / WATER_INDEX("2024-01-01") + assert ratio == pytest.approx(1.27 * 1.054, rel=1e-3) diff --git a/policyengine_uk/tests/test_water_uprating.py b/policyengine_uk/tests/test_water_uprating.py new file mode 100644 index 0000000000..ffc7b610e8 --- /dev/null +++ b/policyengine_uk/tests/test_water_uprating.py @@ -0,0 +1,237 @@ +"""Dataset projection uprates water and sewerage charges by country (#2501). + +Ofwat's series is the average household bill in England and Wales. Scottish +Water's charges are set separately, within limits from the Water Industry +Commission for Scotland, and rose 9.9% in 2025-26 against Ofwat's 26%. +Northern Ireland has no domestic water charges, so any a dataset gives its +households are held flat. +""" + +import functools +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation, Simulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + LEGACY_PROJECTION_END_YEAR, + PROJECTION_END_YEAR, + _unmapped_uprating_indices, + _uprating_index_map, + extend_single_year_dataset, + uprate_water_and_sewerage_charges, + uprating_input_paths, +) +from policyengine_uk.variables.household.demographic.geography import Region + + +REGIONS = [region.name for region in Region] +UPRATED_YEARS = list(range(2023, PROJECTION_END_YEAR + 1)) +GROWTH = parameters.gov.economic_assumptions.yoy_growth +ENGLAND_AND_WALES = GROWTH.ofwat.water_bills +SCOTLAND = GROWTH.scottish_water.water_bills + + +@functools.cache +def _country_of_each_region(): + # The model's own region-to-country mapping (the `country` variable), a + # second path to the one the uprating code takes. + people = {f"person_{region}": {"age": 40} for region in REGIONS} + households = { + f"household_{region}": {"members": [f"person_{region}"], "region": region} + for region in REGIONS + } + sim = Simulation(situation={"people": people, "households": households}) + countries = sim.calculate("country", 2025) + return dict(zip(REGIONS, countries)) + + +def _expected_growth(region, year): + country = _country_of_each_region()[region] + if country == "NORTHERN_IRELAND": + return 0.0 + if country == "SCOTLAND": + return SCOTLAND(year) + # England, Wales and an unknown region take the England and Wales bills. + return ENGLAND_AND_WALES(year) + + +def _uprated(regions, charges, year): + previous_year = SimpleNamespace( + household=pd.DataFrame({"water_and_sewerage_charges": charges}, dtype=float) + ) + current_year = SimpleNamespace( + time_period=str(year), + household=pd.DataFrame( + {"region": regions, "water_and_sewerage_charges": [0.0] * len(charges)} + ), + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + return current_year.household["water_and_sewerage_charges"].to_numpy() + + +def test_scottish_water_growth_matches_its_sources(): + # Scottish Water's announced increases (8.8% approved by WICS on 30 + # January 2024; 9.9% on 28 January 2025; 8.67% on 22 January 2026). + assert SCOTLAND("2024-01-01") == pytest.approx(0.088) + assert SCOTLAND("2025-01-01") == pytest.approx(0.099) + assert SCOTLAND("2026-01-01") == pytest.approx(0.0867) + + +def test_each_country_takes_its_own_water_growth(): + charges = _uprated( + ["LONDON", "WALES", "SCOTLAND", "NORTHERN_IRELAND", "UNKNOWN"], + [500.0] * 5, + 2025, + ) + england_and_wales = 500 * (1 + ENGLAND_AND_WALES("2025-01-01")) + assert charges == pytest.approx( + [england_and_wales, england_and_wales, 500 * 1.099, 500.0, england_and_wales] + ) + + +@pytest.mark.parametrize("region", ["NORTHERN IRELAND", "ATLANTIS", ""]) +def test_unrecognised_regions_are_rejected(region): + with pytest.raises(ValueError, match="unrecognised regions"): + _uprated(["LONDON", region], [500.0, 500.0], 2025) + + +def test_a_dataset_without_water_charges_is_left_alone(): + previous_year = SimpleNamespace(household=pd.DataFrame({"rent": [1.0]})) + current_year = SimpleNamespace( + time_period="2025", household=pd.DataFrame({"region": ["SCOTLAND"]}) + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + assert "water_and_sewerage_charges" not in current_year.household + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + households=st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.floats(0, 5_000, allow_nan=False), + ), + min_size=1, + max_size=30, + ), + year=st.sampled_from(UPRATED_YEARS), + scale=st.floats(0, 10, allow_nan=False), +) +def test_water_uprating_properties(households, year, scale): + """For every Region label, any charge of zero or more and every + projected year: + + - the charge grows by its country's series, with the country taken from + the model's `country` variable: Ofwat's for England, Wales and unknown + regions, Scottish Water's for Scotland, and none for Northern Ireland; + - each household's result depends only on its own row; + - scaling every previous-year charge scales every result by the same + factor. + """ + regions, amounts = (list(column) for column in zip(*households)) + + charges = _uprated(regions, amounts, year) + + expected = [ + amount * (1 + _expected_growth(region, year)) for region, amount in households + ] + assert charges == pytest.approx(expected) + alone = [_uprated([region], [amount], year)[0] for region, amount in households] + assert charges == pytest.approx(alone) + scaled = _uprated(regions, [scale * amount for amount in amounts], year) + assert scaled == pytest.approx(scale * charges) + + +def test_water_charges_are_uprated_once(): + # The country uprating replaces a single index: listing the column in + # uprating_indices.yaml as well, or letting the index its variable + # declares grow it after LEGACY_PROJECTION_END_YEAR, would apply a second + # growth rate on top. + listed = {name for names in _uprating_index_map().values() for name in names} + assert "water_and_sewerage_charges" not in listed + assert "water_and_sewerage_charges" not in _unmapped_uprating_indices( + _uprating_index_map() + ) + + +def test_the_projection_fingerprint_covers_both_water_series(): + paths = uprating_input_paths() + assert "gov.economic_assumptions.yoy_growth.ofwat.water_bills" in paths + assert "gov.economic_assumptions.yoy_growth.scottish_water.water_bills" in paths + + +# A dataset with an unknown-region household cannot be projected forward until +# rent uprating handles Region.UNKNOWN (PolicyEngine/policyengine-uk#1985), so +# the dataset-level tests below use the twelve known regions. +KNOWN_REGIONS = [region for region in REGIONS if region != "UNKNOWN"] + + +def _one_household_per_known_region(charges): + ids = np.arange(1, len(KNOWN_REGIONS) + 1) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 40, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": KNOWN_REGIONS, + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + "water_and_sewerage_charges": charges, + } + ), + fiscal_year=2024, + ) + + +def _compounded(amount, region, year, base_year=2024): + return amount * np.prod( + [1 + _expected_growth(region, y) for y in range(base_year + 1, year + 1)] + ) + + +def test_extended_dataset_compounds_each_country_water_growth(): + # Every projected year chains its country's growth, through the old + # dataset horizon (2030) and the two years after it. + extended = extend_single_year_dataset( + _one_household_per_known_region(500.0), parameters, end_year=2032 + ) + + for year in range(2025, PROJECTION_END_YEAR + 1): + charges = extended[year].household["water_and_sewerage_charges"].to_numpy() + assert charges == pytest.approx( + [_compounded(500.0, region, year) for region in KNOWN_REGIONS] + ) + assert PROJECTION_END_YEAR > LEGACY_PROJECTION_END_YEAR + + +def test_microsimulation_gives_scotland_scottish_water_growth(): + sim = Microsimulation(dataset=_one_household_per_known_region(500.0)) + + charges = dict( + zip(KNOWN_REGIONS, sim.calculate("water_and_sewerage_charges", 2026).values) + ) + # 500 x 1.099 x 1.0867 in Scotland; Northern Ireland stays at 500. + assert charges["SCOTLAND"] == pytest.approx(500 * 1.099 * 1.0867) + assert charges["NORTHERN_IRELAND"] == 500.0 + assert charges["WALES"] == pytest.approx(charges["LONDON"]) + assert charges["LONDON"] == pytest.approx( + 500 + * (1 + ENGLAND_AND_WALES("2025-01-01")) + * (1 + ENGLAND_AND_WALES("2026-01-01")) + ) diff --git a/policyengine_uk/utils/aggregate_impact/compare.py b/policyengine_uk/utils/aggregate_impact/compare.py index e5dbb9c009..dc1a161bcf 100644 --- a/policyengine_uk/utils/aggregate_impact/compare.py +++ b/policyengine_uk/utils/aggregate_impact/compare.py @@ -733,6 +733,8 @@ def field(result, *path): "policyengine-uk version": ("runtime", "policyengine_uk"), "policyengine-uk fingerprint": ("runtime", "policyengine_uk_fingerprint"), "runtime listing": ("runtime", "distributions"), + "Python version": ("runtime", "python"), + "machine": ("runtime", "machine"), "fiscal partition": ("partition", "sha256"), "metric definitions": ("provenance", "metrics_version"), } diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index bf136eaf90..2df7693ab5 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -80,8 +80,13 @@ def award_of_members(benunit, period, award, report, award_on_reports, members): reported = person(report, period) reported_total = benunit.sum(reported) members_reported = benunit.sum(reported * members) - award_on_all_reports = award_on_reports(benunit, period, reported_total) - award_on_members_reports = award_on_reports(benunit, period, members_reported) + # Formula awards omit reports whose own award has closed. Keep the raw + # totals above for disable_simulated_benefits and direct-value provenance. + open_reported = reported * ~person("person_legacy_benefits_closed", period) + award_on_all_reports = award_on_reports(benunit, period, benunit.sum(open_reported)) + award_on_members_reports = award_on_reports( + benunit, period, benunit.sum(open_reported * members) + ) # Compare in the precision the award is stored in (float32), so the # formula's own award always matches the award recomputed here. stored = stored_award.dtype diff --git a/policyengine_uk/utils/bus_fares.py b/policyengine_uk/utils/bus_fares.py index f52a6cfc7e..06e3f0c0f6 100644 --- a/policyengine_uk/utils/bus_fares.py +++ b/policyengine_uk/utils/bus_fares.py @@ -19,3 +19,17 @@ def annual_capped_fare(uncapped, schedule, year): np.minimum(uncapped, max(rates.cap, 0)) * share for rates, share in fiscal_year_segments(schedule, year) ) + + +def single_fare_cap_change(fares, shares, schedule, reference, year): + """Mean change in the price of a single ticket over the fiscal year when + the cap follows ``schedule`` instead of ``reference``. Each uncapped fare + is capped under both and weighted by its share of single tickets.""" + return sum( + share + * ( + annual_capped_fare(fare, schedule, year) + - annual_capped_fare(fare, reference, year) + ) + for fare, share in zip(fares, shares) + ) diff --git a/policyengine_uk/utils/dated_reforms.py b/policyengine_uk/utils/dated_reforms.py index 59751d91bb..53ef5f7796 100644 --- a/policyengine_uk/utils/dated_reforms.py +++ b/policyengine_uk/utils/dated_reforms.py @@ -185,6 +185,12 @@ last_stated_tax_year, ) +from policyengine_uk.utils.ni_parameter_scales import ( + LegacyNIParameter, + canonical_descendants, + canonicalize_ni_parameter_path, +) + from policyengine_uk.utils.parameters import ( BACKDATED_VALUE, check_parameter_not_removed, @@ -348,6 +354,7 @@ def read_reform_dict( legacy: List[LegacyOverride] = [] order = order_start for path, spec in reform.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) statutory = defines(path) if not isinstance(spec, Mapping): @@ -428,6 +435,7 @@ def read_parameter_changes( assignments: List[ParameterAssignment] = [] order = order_start for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) if not isinstance(spec, Mapping): start, stop = period_bounds(CORE_SCALAR_PERIOD) @@ -463,6 +471,7 @@ def read_reform_from_dict( legacy: List[LegacyOverride] = [] order = order_start for path, spec in parameter_values.items(): + path = canonicalize_ni_parameter_path(path) _check_path(path) if not defines(path): if isinstance(spec, Mapping): @@ -616,6 +625,7 @@ def read(spec): except (ValueError, TypeError, KeyError) as original: errors = [] for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) entries = spec.items() if isinstance(spec, Mapping) else [(None, spec)] for key, value in entries: try: @@ -805,6 +815,7 @@ def read(spec, predicate, warn=False): return pending, bool(pending) overrides = [] for path, spec in changes.items(): + path = canonicalize_ni_parameter_path(path) change = {path: spec} assignments, legacy = read(change, defines) if _reform_writes_are_effective(assignments, legacy, system, written): @@ -986,7 +997,7 @@ def snapshot_timelines(node: ParameterNode) -> Dict[str, Timeline]: """ return { parameter.name: parameter.values_list - for parameter in node.get_descendants() + for parameter in canonical_descendants(node) if isinstance(parameter, Parameter) } @@ -1014,6 +1025,8 @@ def graft_missing_nodes( """ added = [] for key, child in source.children.items(): + if isinstance(child, LegacyNIParameter): + continue if root and key == "baseline": continue existing = target.children.get(key) diff --git a/policyengine_uk/utils/lha.py b/policyengine_uk/utils/lha.py index e8ff422c60..c2714ce03f 100644 --- a/policyengine_uk/utils/lha.py +++ b/policyengine_uk/utils/lha.py @@ -313,14 +313,19 @@ def determination( determined: int, universal_credit: bool = False, adjustment: np.ndarray | None = None, + percentile_parameter=None, ): """The rates determined in ``determined`` for every BRMA and category. ``adjustment`` is added to the percentile rents before the rules apply - (see ``restatement``). Returns (percentile, rate) as [BRMA, category] - arrays, weekly for Housing Benefit and monthly for Universal Credit. + (see ``restatement``). ``percentile_parameter`` is the percentile to + determine at, ``gov.dwp.LHA.percentile`` (Great Britain) by default. + Returns (percentile, rate) as [BRMA, category] arrays, weekly for Housing + Benefit and monthly for Universal Credit. """ lha = parameters.gov.dwp.LHA + if percentile_parameter is None: + percentile_parameter = lha.percentile rates = published_rates() if determined < FIRST_RULES_YEAR: @@ -361,7 +366,7 @@ def determination( growth[base_year == value] = index(str(determined)) / index(str(value)) percentile = percentile * growth - share = lha.percentile(str(determined)) + share = percentile_parameter(str(determined)) if abs(share - PUBLISHED_PERCENTILE) > 1e-9: percentile = percentile * _percentile_ratios(float(share)) if adjustment is not None: @@ -399,16 +404,52 @@ def lha_rates(parameters, year: int, universal_credit: bool = False) -> dict: minimum rules) and ``rate`` (the LHA in force), both [BRMA, category] arrays, weekly for Housing Benefit and monthly for Universal Credit. """ - lha = parameters.gov.dwp.LHA + great_britain = parameters.gov.dwp.LHA + northern_ireland = parameters.gov.dfc.LHA + percentile, rate = _rates_in_force( + parameters, year, universal_credit, great_britain + ) + # The Rent Officers Orders behind gov.dwp.LHA cover Great Britain; the + # Northern Ireland Housing Executive determines Northern Ireland's rates + # under Northern Ireland regulations, which gov.dfc.LHA follows. Where + # the two would give the same determination, the Great Britain table + # already holds Northern Ireland's rates. + gb_year = determination_year(great_britain, year) + ni_year = determination_year(northern_ireland, year) + if ni_year != gb_year or great_britain.percentile( + str(gb_year) + ) != northern_ireland.percentile(str(ni_year)): + ni_percentile, ni_rate = _rates_in_force( + parameters, year, universal_credit, northern_ireland + ) + in_ni = northern_ireland_brmas()[:, None] + percentile = np.where(in_ni, ni_percentile, percentile) + rate = np.where(in_ni, ni_rate, rate) + return dict(brmas=published_rates().brmas, percentile=percentile, rate=rate) + + +def _rates_in_force(parameters, year: int, universal_credit: bool, lha) -> tuple: + """(percentile, rate) in force in ``year`` under one nation's parameters. + + ``lha`` is the parameter node holding ``freeze`` and ``percentile``: + ``gov.dwp.LHA`` for Great Britain or ``gov.dfc.LHA`` for Northern Ireland. + """ determined = determination_year(lha, year) adjustment = None if determined != year and determined >= FIRST_RULES_YEAR: measure = "uc_rate" if universal_credit else "rate" adjustment = restatement(measure, determined, year) - percentile, rate = determination( - parameters, determined, universal_credit, adjustment + return determination( + parameters, determined, universal_credit, adjustment, lha.percentile ) - return dict(brmas=published_rates().brmas, percentile=percentile, rate=rate) + + +@lru_cache(maxsize=1) +def northern_ireland_brmas() -> np.ndarray: + """Whether each BRMA in the published tables is in Northern Ireland.""" + households = pd.read_csv(LHA_DIRECTORY / "brma_private_rented_households.csv.gz") + names = households.brma[households.region == "NORTHERN_IRELAND"].unique() + return published_rates().brmas.isin(names) def benunit_lha( diff --git a/policyengine_uk/utils/ni_parameter_scales.py b/policyengine_uk/utils/ni_parameter_scales.py new file mode 100644 index 0000000000..cecb696298 --- /dev/null +++ b/policyengine_uk/utils/ni_parameter_scales.py @@ -0,0 +1,377 @@ +"""Single-source NI scale leaves with legacy scalar read/update compatibility. + +Views are discoverable Parameters for existing metadata serializers. Numerical +processing explicitly excludes them; no view owns a second policy timeline. +Retire the Core-internal adaptations when stable alias/processing contracts +replace them. Remove legacy views through an explicit compatibility migration +once callers, saved reforms and metadata consumers use canonical scale paths; +retain their contract tests until that transition is complete. +""" + +import copy +from contextlib import contextmanager +from dataclasses import dataclass +from types import MappingProxyType + +from policyengine_core.commons import empty_clone +from policyengine_core.parameters import Parameter, ParameterNode, ParameterScale + +NI_PREFIX = "gov.hmrc.national_insurance." +NI_LEGACY_PATHS = MappingProxyType( + { + NI_PREFIX + old: NI_PREFIX + new + for old, new in { + "class_1.rates.employee.main": "class_1.employee_scale[1].rate", + "class_1.rates.employee.additional": "class_1.employee_scale[2].rate", + "class_1.thresholds.primary_threshold": "class_1.employee_scale[1].threshold", + "class_1.thresholds.upper_earnings_limit": "class_1.employee_scale[2].threshold", + "class_1.rates.employer": "class_1.employer_scale[1].rate", + "class_1.thresholds.secondary_threshold": "class_1.employer_scale[1].threshold", + "class_4.rates.main": "class_4.contribution_scale[1].rate", + "class_4.rates.additional": "class_4.contribution_scale[2].rate", + "class_4.thresholds.lower_profits_limit": "class_4.contribution_scale[1].threshold", + "class_4.thresholds.upper_profits_limit": "class_4.contribution_scale[2].threshold", + }.items() + } +) + + +def canonicalize_ni_parameter_path(path): + prefix = "" + while path.startswith("baseline."): + prefix += "baseline." + path = path[len("baseline.") :] + return prefix + NI_LEGACY_PATHS.get(path, path) + + +@dataclass(frozen=True) +class NIComponent: + """Original bracket identity, even when custom thresholds coincide/cross.""" + + threshold: object + rate: object + + +def _validate_ni_zero_bracket(scale): + """The initial zero band is a fixed representation sentinel.""" + bracket = scale.brackets[0] + for name in ("threshold", "rate"): + leaf = bracket.children.get(name) + if ( + not isinstance(leaf, Parameter) + or not leaf.values_list + or any(point.value != 0 for point in leaf.values_list) + ): + raise ValueError( + f"NI canonical leaf {scale.name}[0].{name} is fixed at zero; " + "update the contribution bands after bracket 0 instead" + ) + + +class NIParameterScale(ParameterScale): + def _get_at_instant(self, instant): + _validate_ni_zero_bracket(self) + scale = super()._get_at_instant(instant) + scale.components = tuple( + NIComponent(bracket.threshold(instant), bracket.rate(instant)) + for bracket in self.brackets + ) + return scale + + +class LegacyNIParameter(Parameter): + """A named scalar descriptor whose values belong to a canonical NI leaf.""" + + def __init__(self, name, target_path): + self.name = name + self.target_path = target_path + self.parent = None + self._at_instant_cache = {} + + @property + def target(self): + node = self.parent + while node is not None and not getattr(node, "_ni_scale_anchor", False): + node = getattr(node, "parent", None) + if node is None: + raise ValueError( + f"Detached NI compatibility view {self.name} has no policy tree" + ) + return node.get_child(self.target_path) + + @property + def values_list(self): + return self.target.values_list + + @values_list.setter + def values_list(self, value): + target = self.target + target.values_list = value + target.clear_parent_cache() + target.mark_as_modified() + + @property + def metadata(self): + return self.target.metadata + + @metadata.setter + def metadata(self, value): + self.target.metadata = value + + @property + def description(self): + return self.target.description + + @description.setter + def description(self, value): + self.target.description = value + + @property + def documentation(self): + return getattr(self.target, "documentation", None) + + @property + def file_path(self): + return self.target.file_path + + def update(self, *args, **kwargs): + return self.target.update(*args, **kwargs) + + def _get_at_instant(self, instant): + return self.target.get_at_instant(instant) + + def clear_parent_cache(self): + self.target.clear_parent_cache() + + def mark_as_modified(self): + self.target.mark_as_modified() + + def clone(self): + # A standalone leaf clone is an independent ordinary Parameter, as it + # was before migration. Parent cloning recreates descriptors instead. + leaf = self.target.clone() + leaf.name = self.name + owner = ParameterNode("", data={}) + owner.add_child("leaf", leaf) + return leaf + + +class LegacyNIDiscoveryParameter(LegacyNIParameter): + """Presentation-only alias; direct lookup keeps canonical metadata identity.""" + + @property + def metadata(self): + return {**self.target.metadata, "household": False, "economy": False} + + +class NICompatibilityNode(ParameterNode): + def get_descendants(self): + # Enumerators such as the API expose both stable public IDs, but only + # the canonical leaf is an editable discovery control. The registered + # child itself is unchanged for get_parameter/manual-child updates. + for child in self.children.values(): + if isinstance(child, LegacyNIParameter): + view = LegacyNIDiscoveryParameter(child.name, child.target_path) + view.parent = child.parent + yield view + else: + yield child + yield from child.get_descendants() + + def clone(self): + clone = empty_clone(self) + clone.__dict__ = self.__dict__.copy() + clone.metadata = copy.deepcopy(self.metadata) + clone.children = { + key: LegacyNIParameter(child.name, child.target_path) + if isinstance(child, LegacyNIParameter) + else child.clone() + for key, child in self.children.items() + } + for key, child in clone.children.items(): + setattr(clone, key, child) + child.parent = clone + clone._at_instant_cache = {} + return clone + + def _get_at_instant(self, instant): + # A sibling canonical update clears the root's cache, not a legacy + # sibling's cache. Rebuild this cheap numeric view on every lookup. + self._at_instant_cache.pop(instant, None) + return super()._get_at_instant(instant) + + +class _CanonicalChildren(dict): + """Explicit processing iteration, retaining legacy key lookups.""" + + def items(self): + return ( + (key, value) + for key, value in super().items() + if not isinstance(value, LegacyNIParameter) + ) + + def values(self): + return (value for _, value in self.items()) + + def __iter__(self): + return (key for key, _ in self.items()) + + +def canonical_descendants(node): + return ( + child + for child in node.get_descendants() + if not isinstance(child, LegacyNIParameter) + ) + + +@contextmanager +def canonical_ni_processing(parameters): + """Only on an owned processing tree; restore discovery even on errors. + + The real root/leaves and their parents retain identity. Core's nested + traversal and its independent child/path walker see canonical leaves; + explicit legacy lookup still resolves the view. No global/caller mode. + """ + saved = [] + try: + for node in list(parameters.get_descendants()): + if isinstance(node, ParameterNode) and any( + isinstance(child, LegacyNIParameter) for child in node.children.values() + ): + saved.append((node, node.children)) + node.children = _CanonicalChildren(node.children) + yield parameters + finally: + for node, children in reversed(saved): + node.children = children + + +@contextmanager +def canonical_ni_uprating_references(parameters): + """Map NI dependency names for Core's canonical-leaf ordering only.""" + saved = [] + try: + for leaf in canonical_descendants(parameters): + if not isinstance(leaf, Parameter): + continue + value = leaf.metadata.get("uprating") + path = ( + value + if isinstance(value, str) + else (value.get("parameter") if isinstance(value, dict) else None) + ) + if path is None: + continue + canonical = canonicalize_ni_parameter_path(path) + if canonical != path: + saved.append((leaf.metadata, value)) + leaf.metadata["uprating"] = ( + canonical + if isinstance(value, str) + else { + **value, + "parameter": canonical, + } + ) + yield parameters + finally: + for metadata, value in reversed(saved): + metadata["uprating"] = value + + +def add_ni_scale_compatibility(parameters): + """Install relative, clone-safe views without copied scalar histories.""" + # Baseline copies use their own NI anchor and canonical components. + roots = [parameters] + if "baseline" in parameters.children: + roots.append(parameters.children["baseline"]) + for root in roots: + ni = root.gov.hmrc.national_insurance + for name in ( + "class_1.employee_scale", + "class_1.employer_scale", + "class_4.contribution_scale", + ): + scale = ni.get_child(name) + scale.__class__ = NIParameterScale + ni._ni_scale_anchor = True + for old, new in NI_LEGACY_PATHS.items(): + parts = old[len(NI_PREFIX) :].split(".") + node = ni + for part in parts[:-1]: + node.__class__ = NICompatibilityNode + if part not in node.children: + node.add_child( + part, NICompatibilityNode(node.name + "." + part, data={}) + ) + node = node.children[part] + if not isinstance(node, ParameterNode): + raise ValueError( + f"NI legacy subtree {old} was structurally replaced; use {new}" + ) + node.__class__ = NICompatibilityNode + previous = node.children.get(parts[-1]) + if previous is not None and not isinstance(previous, LegacyNIParameter): + raise ValueError( + f"NI legacy leaf {old} was structurally replaced; update {new}" + ) + if previous is None: + node.add_child(parts[-1], LegacyNIParameter(old, new[len(NI_PREFIX) :])) + return parameters + + +def validate_ni_scale_compatibility(parameters): + """Reject conflicting subtree replacement before numerical processing. + + Leaf updates and unrelated added nodes are supported. Replacing either + spelling's structure is not silently interpreted as a second schedule. + """ + roots = [parameters] + if "baseline" in parameters.children: + roots.append(parameters.children["baseline"]) + for root in roots: + try: + ni = root.gov.hmrc.national_insurance + except (AttributeError, ValueError) as error: + raise ValueError( + "NI canonical subtree was structurally replaced; retain the schedules and update component leaves" + ) from error + for path, count in ( + ("class_1.employee_scale", 3), + ("class_1.employer_scale", 2), + ("class_4.contribution_scale", 3), + ): + try: + scale = ni.get_child(path) + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI canonical schedule {path} was structurally replaced; update its component leaves" + ) from error + if not isinstance(scale, NIParameterScale) or len(scale.brackets) != count: + raise ValueError( + f"NI canonical schedule {path} was structurally replaced; update its component leaves" + ) + _validate_ni_zero_bracket(scale) + for old, new in NI_LEGACY_PATHS.items(): + node = ni + try: + for part in old[len(NI_PREFIX) :].split("."): + node = node.children[part] + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI legacy path {old} was structurally replaced; update {new}" + ) from error + try: + valid = isinstance( + node, LegacyNIParameter + ) and node.target is root.get_child(new) + except (KeyError, AttributeError, ValueError) as error: + raise ValueError( + f"NI canonical leaf {new} was structurally replaced; update its value" + ) from error + if not valid: + raise ValueError( + f"NI legacy path {old} was structurally replaced; update {new}" + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 66f93aa66a..3ffd145a18 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -4,6 +4,12 @@ from policyengine_core.model_api import * from policyengine_core import periods +from policyengine_uk.utils.ni_parameter_scales import ( + add_ni_scale_compatibility, + canonicalize_ni_parameter_path, + canonical_descendants, +) + from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( canonicalize_lsr_parameter_path, ) @@ -25,7 +31,7 @@ def str_to_instant(s): def backdate_parameters(root: str = None, first_instant: str = "2021-01-01") -> Reform: first_instant = str_to_instant(first_instant) node = root - for param in node.get_descendants(): + for param in canonical_descendants(node): if isinstance(param, Parameter): earliest = param.values_list[-1] earliest_value = earliest.value @@ -117,7 +123,7 @@ def convert_to_fiscal_year_parameters(parameters): """ # Cover years from 2015 through 2040 for long-term projections YEARS = list(range(2015, 2041)) - for param in parameters.get_descendants(): + for param in canonical_descendants(parameters): if isinstance(param, Parameter): if (param.metadata or {}).get("preserve_calendar_dates", False): continue @@ -186,6 +192,21 @@ class RemovedParameter(NamedTuple): "income that is not qualifying income for Savings Credit (SPC Regs " "2002 reg 9), instead.", ), + "gov.dft.bus.reference_year": RemovedParameter( + "removed bus fare reference year", + "Bus fares now follow gov.dft.bus.local_bus_fares_index from the " + "benchmark year gov.dft.bus.benchmark_year, and the cap term compares " + "gov.dft.bus.fares.cap with gov.dft.bus.reference_fares.cap in the same " + "year. Change gov.dft.bus.fares.cap to model a cap.", + ), + "gov.dft.bus.uncapped_single_fare": RemovedParameter( + "removed single uncapped bus fare", + "Cap changes are priced from a table of uncapped single fares, " + "gov.dft.bus.uncapped_single_fares, with the share of single tickets " + "and the mean fare of each band (LOW, MIDDLE, HIGH). To price every " + "single ticket at one fare, set that band's share to 1 and the others " + "to 0, or supply local_bus_uncapped_single_fare for each person.", + ), } @@ -225,7 +246,7 @@ class RemovedParameterRoot(ParameterNode): def get_child(self, path: str): check_parameter_not_removed(path) - return super().get_child(path) + return super().get_child(canonicalize_ni_parameter_path(path)) def add_removed_parameter_aliases(parameters: ParameterNode) -> ParameterNode: @@ -265,4 +286,4 @@ def add_removed_parameter_aliases(parameters: ParameterNode) -> ParameterNode: parent.children[name] = alias setattr(parent, name, alias) alias.parent = parent - return parameters + return add_ni_scale_compatibility(parameters) diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 4c9b81729c..8b5ecb3ff0 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -3,16 +3,42 @@ from policyengine_uk.utils.supplied_inputs import supplied_input -def relevant_uk_earnings(person, period): - """FA 2004 s.189 earnings, including employer-paid taxable SSP/SMP. +def trading_profits_before_losses(person, period, hmrc): + """Trading profits chargeable under ITTOIA 2005 Part 2, before losses. - Statutory employment pay is held separately from wages. A trading loss - does not reduce those employment earnings (HMRC PTM044100). + Capital allowances are a trading expense (CAA 2001 s.247) and the trading + allowance replaces expenses (ITTOIA 2005 s.783AC), so both come off. + Losses brought forward (ITA 2007 s.83) are a Step 2 relief, which the + model deducts from trading profits (loss_relief); they stay in here. """ - return ( - person("employment_income", period) - + person("employment_benefits", period) - + np.maximum(0, person("self_employment_income", period)) + from policyengine_core.model_api import add + + deductions = [ + deduction + for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions + if deduction != "loss_relief" + ] + return np.maximum( + 0, person("self_employment_income", period) - add(person, period, deductions) + ) + + +def relevant_uk_earnings(person, period, hmrc): + """FA 2004 s.189(2) relevant UK earnings. + + Employment income chargeable to income tax is net taxable earnings + (ITEPA 2003 ss.9(2) and 11): pay and employer-paid taxable SSP/SMP, which + the model holds separately from wages, less the allowable expenses in + s.327(3). s.327(5) also lists the deductions for pension contributions + (FA 2004 ss.188-194), but those are the relief that these earnings limit, + so they are not taken off; the limit would otherwise depend on itself. + Trading income is the profit chargeable under ITTOIA 2005 Part 2, before + losses. A trading loss does not reduce employment earnings (HMRC + PTM044100). Furnished holiday lettings profits, relevant UK earnings + before 2025-26 (s.189(2)(ba) and (bb)), are not modelled separately. + """ + return person("taxable_employment_income", period) + ( + trading_profits_before_losses(person, period, hmrc) ) @@ -23,8 +49,7 @@ def pension_contributions_relief_amount( Only RAS can use the basic-amount increase above earnings (s. 191(7)). The annual allowance does not cap relief, and payments from age 75 are - unrelievable. Keeping this calculation shared lets the AA income tests - exclude the NICs-only salary-sacrifice reclassification exactly. + unrelievable. The AA income tests use the same actual-payment convention. """ basic_amount_increase = np.minimum( np.maximum( @@ -40,24 +65,21 @@ def pension_contributions_relief_amount( def annual_allowance_net_income(person, period, parameters): - """Model net income rebuilt without NICs-only returned salary sacrifice. + """Model net income with the annual-allowance net-pay/loss ordering. Like net_income, this is before the separately modelled pension relief. - ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction, not - subtracting returned pay after an income-dependent deduction was taken. - Ordinary income tax keeps its existing calculation (issue #2454). + ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction after + relievable net pay. A supplied legacy return is actual pay/member payment; + the computed NICs-only excess never enters income or contributions. """ - from policyengine_core.model_api import add - - returned = person("salary_sacrifice_returned_to_income", period) hmrc = parameters(period).gov.hmrc - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned + actual_member_contributions = person("pension_contributions", period) + actual_earnings = relevant_uk_earnings(person, period, hmrc) supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. # Restore only Gift Aid and pension relief on actual earnings/payments; - # neither returned pay nor a replacement loss deduction belongs here. + # A replacement loss deduction does not belong on this input path. return ( supplied_ani + person("gift_aid_grossed_up", period) @@ -71,41 +93,25 @@ def annual_allowance_net_income(person, period, parameters): ) net_income = person("net_income", period) - actual_net_pay = person("employee_pension_contributions", period) - returned + actual_net_pay = person("employee_pension_contributions", period) relievable_net_pay = pension_contributions_relief_amount( actual_net_pay, actual_earnings, 0, person("age", period), hmrc ) - if not np.any(returned) and not np.any( - (relievable_net_pay > 0) & (person("trading_loss", period) > 0) - ): + if not np.any((relievable_net_pay > 0) & (person("trading_loss", period) > 0)): return net_income - taxable_pay = person("taxable_employment_income", period) - actual_taxable_pay = np.maximum(0, taxable_pay - returned) - removed_taxable_pay = taxable_pay - actual_taxable_pay income_before_loss = np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - removed_taxable_pay, + 0, person("net_income_before_trade_loss_relief", period) ) # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member # contributions, retaining its treatment of brought-forward trade losses # and pension relief. Brought-forward losses only reduce trading profits. - other_deductions = [ - deduction - for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = np.maximum( - 0, - person("self_employment_income", period) - - add(person, period, other_deductions), - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) relieved_contributions = pension_contributions_relief_amount( actual_member_contributions, - actual_taxable_pay + profits_before_carry_forward, + actual_earnings, person("personal_pension_contributions", period), person("age", period), hmrc, @@ -127,16 +133,58 @@ def annual_allowance_net_income(person, period, parameters): # below so the callers can deduct or restore member relief once. income_after_net_pay = np.maximum(0, income_before_loss - relievable_net_pay) actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_after_net_pay)) - # Add back the loss computed using synthetic pay before deducting the - # actual-pay loss. Supplied ANI has its own path above. For other people, - # net-pay/loss ordering also applies before the NICs cap starts. + # Replace the original loss with the loss after relievable net pay. + # Supplied ANI has its own path above. This ordering also applies before + # the NICs cap starts and when the actual legacy return is zero. reconstructed = np.maximum( 0, net_income + person("trade_loss_relief_against_general_income", period) - - removed_taxable_pay - actual_loss_relief, ) + return np.where(relievable_net_pay > 0, reconstructed, net_income) + + +def annual_allowance_for_year(adjusted_income, threshold_income, allowance, year): + """The annual allowance (FA 2004 s.228) under one tax year's parameters. + + From 2016-17 the standard allowance is tapered only when both adjusted + income and threshold income exceed their limits (s.228ZA), and the + reduction rounds down to whole pounds before the tapered minimum + applies. The original multiplier is the standard allowance less £10,000 + over £60,000, a half at the statutory £40,000; from 2020-21 the + multiplier is expressly a half. + """ + adjusted_income = np.asarray(adjusted_income, dtype=float) + if year < 2016: + return np.full(adjusted_income.shape, float(allowance.default)) + if year < 2020: + rate = (allowance.default - allowance.minimum) / allowance.taper_income_range + else: + rate = allowance.reduction_rate + # s.228ZA(3)(b) derives the threshold limit from the standard allowance. + high_income = (adjusted_income > allowance.taper) & ( + np.asarray(threshold_income) > allowance.taper - allowance.default + ) + reduction = np.floor(np.maximum(0, adjusted_income - allowance.taper) * rate) return np.where( - (returned > 0) | (relievable_net_pay > 0), reconstructed, net_income + high_income, + np.maximum(allowance.minimum, allowance.default - reduction), + allowance.default, + ) + + +def workplace_pension_contributions(person, period): + """Contributions to workplace arrangements. + + Employer contributions, employee contributions under a net pay + arrangement (including any supplied legacy return to ordinary pay, an + actual member payment) and the full salary sacrifice. The NICs-only + salary-sacrifice excess is not a contribution. Personal pension + contributions (relief at source) are not included. + """ + return ( + person("employee_pension_contributions", period) + + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) ) diff --git a/policyengine_uk/utils/pip.py b/policyengine_uk/utils/pip.py new file mode 100644 index 0000000000..e47a904353 --- /dev/null +++ b/policyengine_uk/utils/pip.py @@ -0,0 +1,72 @@ +"""Shared logic for the PIP component categories.""" + +import numpy as np +from policyengine_core.periods import Period + +from policyengine_uk.model_api import select +from policyengine_uk.utils.supplied_inputs import ( + supplied_input, + supplied_input_periods, +) +from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory + +# The default of the points inputs: the person was not assessed. Zero is an +# assessment that scored no points. +NOT_ASSESSED = -1 + + +def pip_category(person, period: Period, thresholds, component: str): + """The PIP category for ``component`` ("dl" or "m") in ``period``. + + A category supplied for the period is an input, so this only runs when + there is none. Each person's evidence is then the more recent of their + points and the category supplied for an earlier year, which carries + forward as it would for an input-only variable: + + - a person's points come from the latest year up to ``period`` for + which they were assessed (points other than ``NOT_ASSESSED``); + - they decide the category if they are for a later year than the latest + earlier category, or if there is no earlier category; + - otherwise the earlier category carries forward. With neither, the + category is NONE. + + Points are decided per person: one person's points for a year leave + another person's carried category alone. A category supplied for a year + is stored for the whole population, so it applies to everyone that year, + with NONE for anyone not given one, as before the points existed. + """ + category_variable = f"pip_{component}_category" + points_variable = f"pip_{component}_points" + + earlier_categories = [ + p + for p in supplied_input_periods(person, category_variable) + if p.start < period.start + ] + category_period = earlier_categories[-1] if earlier_categories else None + + points = np.full(person.count, NOT_ASSESSED) + # Whether the person's latest points are for a later year than the + # latest earlier category (always, if there is no such category). + points_are_newer = np.zeros(person.count, dtype=bool) + for points_period in supplied_input_periods(person, points_variable): + if points_period.start > period.start: + break + values = supplied_input(person, points_variable, points_period) + assessed = values != NOT_ASSESSED + points = np.where(assessed, values, points) + points_are_newer = np.where( + assessed, + category_period is None or points_period.start > category_period.start, + points_are_newer, + ) + + from_points = select( + [points >= thresholds.enhanced, points >= thresholds.standard], + [PIPCategory.ENHANCED.index, PIPCategory.STANDARD.index], + default=PIPCategory.NONE.index, + ) + if category_period is None: + return from_points + carried = np.asarray(supplied_input(person, category_variable, category_period)) + return np.where(points_are_newer, from_points, carried) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index a6a7600e6c..4a3c5ff1ce 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -43,7 +43,12 @@ "bus_fare_spending", "bus_in_london_trips", "other_local_bus_trips", + "pip_dl_category", + "pip_dl_points", + "pip_m_category", + "pip_m_points", "local_bus_trips", + "local_bus_uncapped_single_fare", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py index 863a35adfa..d4e2d50547 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py @@ -29,8 +29,8 @@ def formula(person, period, parameters): # Calculate employer cost parameters = parameters(period) class_1 = parameters.gov.hmrc.national_insurance.class_1 - r_r = class_1.rates.employer - t_r = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_r = class_1.employer_scale.components[1].rate + t_r = class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_r = parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py index 3f32b7291e..0a929b7530 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py @@ -39,8 +39,8 @@ def formula(person, period, parameters): period ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 - r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_b = baseline_class_1.employer_scale.components[1].rate + t_b = baseline_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py index be4aa17063..37af8cc502 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py @@ -47,8 +47,8 @@ def formula(person, period, parameters): ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 - r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_b = baseline_class_1.employer_scale.components[1].rate + t_b = baseline_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b else 0 @@ -60,8 +60,8 @@ def formula(person, period, parameters): # Calculate reform employer cost reform_parameters = parameters(period) reform_class_1 = reform_parameters.gov.hmrc.national_insurance.class_1 - r_r = reform_class_1.rates.employer - t_r = reform_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + r_r = reform_class_1.employer_scale.components[1].rate + t_r = reform_class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR p_r = reform_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r else 0 diff --git a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py index 36a43e8967..0ffd93b844 100644 --- a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py +++ b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py @@ -3,11 +3,14 @@ class local_bus_uncapped_single_fare(Variable): label = "local bus uncapped single fare" - documentation = "Representative uncapped single fare for other-local-bus journeys, in pounds. Override to model a known fare; the default is an aggregate no-demand-response counterfactual." + documentation = ( + "Optional uncapped single fare for a person's other-local-bus journeys, " + "in pounds. When supplied, the person's single tickets are capped at " + "this one fare; otherwise the model uses the table of uncapped single " + "fares in gov.dft.bus.uncapped_single_fares. Where nothing is supplied " + "it reads 0, which the model does not use as a fare." + ) entity = Person definition_period = YEAR value_type = float unit = GBP - - def formula(person, period, parameters): - return parameters(period).gov.dft.bus.uncapped_single_fare diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py index 208e33512b..34ecb063d8 100644 --- a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -1,15 +1,16 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.bus_fares import annual_capped_fare, has_bus_journeys +from policyengine_uk.utils.bus_fares import has_bus_journeys, single_fare_cap_change from policyengine_uk.utils.supplied_inputs import supplied_input class person_bus_fare_spending(Variable): label = "personal bus and coach fare spending" documentation = ( - "Fare-paying bus journeys priced at the published yield, with changes " - "in the single-fare cap applied only to single-ticket boardings in " - "England outside London. Without usable journey data, allocates " - "reported household spending by age. See docs/engineering/bus-fares.md." + "Fare-paying bus journeys priced at the published yield, moved by each " + "area's published fares index, with changes in the single-fare cap " + "applied only to single-ticket boardings in England outside London. " + "Without usable journey data, allocates reported household spending " + "by age. See docs/engineering/bus-fares.md." ) entity = Person definition_period = YEAR @@ -56,28 +57,61 @@ def formula(person, period, parameters): [p.boardings_per_trip.SCOTLAND, p.boardings_per_trip.NORTHERN_IRELAND], default=p.boardings_per_trip.ENGLAND_OUTSIDE_LONDON, ) + # Each area's benchmark yield moves with its published fares index. + # Northern Ireland has no index, so its yield moves with CPI. + benchmark = parameters(f"{int(p.benchmark_year)}-01-01").gov + index = p.local_bus_fares_index + benchmark_index = benchmark.dft.bus.local_bus_fares_index + cpi_growth = ( + parameters(period).gov.economic_assumptions.indices.obr.consumer_price_index + / benchmark.economic_assumptions.indices.obr.consumer_price_index + ) + london_yield = ( + p.yield_per_boarding.LONDON * index.LONDON / benchmark_index.LONDON + ) other_yield = select( [in_scotland, in_ni], - [p.yield_per_boarding.SCOTLAND, p.yield_per_boarding.NORTHERN_IRELAND], - default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON, + [ + p.yield_per_boarding.SCOTLAND + * index.SCOTLAND + / benchmark_index.SCOTLAND, + p.yield_per_boarding.NORTHERN_IRELAND * cpi_growth, + ], + default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON + * index.ENGLAND_OUTSIDE_LONDON + / benchmark_index.ENGLAND_OUTSIDE_LONDON, ) - uncapped = max_(person("local_bus_uncapped_single_fare", period), 0) - current_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.fares, period.start.year - ) - reference_single = annual_capped_fare( - uncapped, parameters.gov.dft.bus.reference_fares, int(p.reference_year) + # The index already reflects the cap in reference_fares. Add the + # effect of the cap differing from it in the same year: scheduled + # changes after the last published quarter, and reforms. Single + # tickets cost the person's supplied uncapped fare, or else follow + # the table of uncapped single fares. + supplied_fare = supplied_input(person, "local_bus_uncapped_single_fare", period) + if supplied_fare is None: + bands = p.uncapped_single_fares + shares = [bands.share.LOW, bands.share.MIDDLE, bands.share.HIGH] + if min(shares) < 0 or abs(sum(shares) - 1) > 1e-6: + raise ValueError( + "The shares in gov.dft.bus.uncapped_single_fares.share must " + "be non-negative and sum to one" + ) + fares = [bands.fare.LOW, bands.fare.MIDDLE, bands.fare.HIGH] + else: + shares, fares = [1], [supplied_fare] + cap_change = single_fare_cap_change( + [max_(fare, 0) for fare in fares], + shares, + parameters.gov.dft.bus.fares, + parameters.gov.dft.bus.reference_fares, + period.start.year, ) single_share = clip(person("local_bus_single_fare_share", period), 0, 1) other_yield = max_( - other_yield - + in_england * single_share * (current_single - reference_single), + other_yield + in_england * single_share * cap_change, 0, ) spending = ( - max_(london_trips, 0) - * p.boardings_per_trip.LONDON - * p.yield_per_boarding.LONDON + max_(london_trips, 0) * p.boardings_per_trip.LONDON * london_yield + max_(other_trips, 0) * other_boardings * other_yield ) spending *= ( diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 21b8f01b5d..cee5d2c28a 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -53,28 +53,14 @@ def own_report_is_paid(person, period, award, works, capital_limit): cannot stand in for theirs, because when the claimant or partner reports an award it tests only them. - The family's legacy benefits must not have closed - (legacy_benefits_closed). The model ends every award in the benefit unit - once they close, and so the benefit unit's award; this person's status - follows it. That is a simplification: in law the migration notice goes - to the claimant and partner (SI 2014/1230 reg 44(1), (4)) and ends their - awards (reg 46(1)), not the award of a member who claims in their own - right. The benefit unit's award must also be in payment: positive, or - nil while the benefit unit's screen fails and the award is not - neutralised. When the award on this person's report alone is positive - and the family's legacy benefits are open, that screen fails only when - the claimant or partner reports an award and fails the work tests, - which leaves this person's own claim standing; on the formula path that - is the only way the award is nil. A zero cannot say why it is zero, so - a reform that replaces the award with nil, or a nil award entered - directly, removes the status only while the screen passes. A reform - that neutralises the benefit always removes it. - - Apart from the closure, on the formula path another member's report or - work never changes this. Their own tests read only their own work, - their own report and the household's capital, and when the award on - their report alone is positive, the benefit unit's award is positive - exactly when its screen passes.""" + Their own legacy benefits must remain open under the existing annual + proxy (person_legacy_benefits_closed). A couple's closure does not end + this separate claim. The benefit unit's award must also be in payment: + positive, or nil while its work/capital screen fails and the award is not + neutralised. A directly entered or reformed nil removes status while + that screen passes; neutralising the award always removes it. These + existing value-based override rules and shared-capital proxy are retained. + """ benunit = person.benunit own_award = ( ~works @@ -84,10 +70,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): > benunit(f"{award}_tariff_income", period) ) ) - # The screen also fails once the family's legacy benefits have closed; - # the model then ends every member's award (a simplification: SI - # 2014/1230 reg 46(1) ends only the claimant's and partner's). - closed = benunit("legacy_benefits_closed", period) + closed = person("person_legacy_benefits_closed", period) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index a476661b78..7ef5d894d6 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -7,6 +7,12 @@ class additional_state_pension(Variable): definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150", + "https://www.legislation.gov.uk/ukpga/1992/5/section/151A", + "https://www.legislation.gov.uk/uksi/2026/148/article/4/made", + "https://www.legislation.gov.uk/uksi/2026/148/article/6/made", + ) def formula(person, period, parameters): simulation = person.simulation @@ -29,21 +35,18 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) types = pension_type.possible_values - bsp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount - nsp_amount = parameters.gov.dwp.state_pension.new_state_pension.amount - # Each pension type has its own flat-rate ceiling; anything above # the ceiling is an add-on: # BASIC → SERPS / S2P (pre-2016 earnings-related top-up) # NEW → Protected Payment (pre-2016 accrual exceeding the # new flat rate, folded into NSP under current law) # state_pension_reported was observed under the legislated rates, so - # split it at the baseline's data-year rate, not the reformed one. - # Otherwise a reform that also sets the data year's rate (an undated - # reform, or any reform in a simulation without a dataset, where the - # data year is the period) leaves the period-over-data-year ratio at 1, - # and state_pension does not follow the reform - # (PolicyEngine/policyengine-uk#2122). + # split it at the baseline's data-year rate, not the reformed one, as + # basic_state_pension and new_state_pension do. Otherwise a reform + # that also sets the data year's rate (an undated reform, or any + # reform in a simulation without a dataset, where the data year is + # the period) would move part of the reported amount between the + # flat-rate part and the add-on (PolicyEngine/policyengine-uk#2122). baseline = getattr(simulation, "baseline", None) legislated = ( baseline.tax_benefit_system.parameters @@ -58,23 +61,25 @@ def formula(person, period, parameters): ], default=0, ) - max_for_type_period = select( - [pension_type == types.BASIC, pension_type == types.NEW], - [bsp_amount(period), nsp_amount(period)], - default=0, - ) - amount_in_data_year = where( pension_type != types.NONE, max_(reported - max_for_type_data, 0), 0, ) - uprating = np.divide( - max_for_type_period, - max_for_type_data, - out=np.ones_like(max_for_type_data, dtype=float), - where=max_for_type_data > 0, + # The full rates rise with the triple lock, but the add-on rises with + # prices: each year's Up-rating Order raises additional pensions and + # the amounts of new State Pension above the full rate by September + # CPI (Social Security Administration Act 1992 ss.150(1)(c), 151(1) + # and 151A; e.g. SI 2026/148 arts 4(3) and 6(3)(b), 3.8%, against + # 4.8% for the full rates). So it is uprated from the data year by + # September CPI, not by the full rate, and a reform to the full rates + # leaves it alone (PolicyEngine/policyengine-uk#1941). The index is + # read from this simulation's parameters, so a price scenario moves + # it. + september_cpi = ( + parameters.gov.economic_assumptions.indices.september_cpi_uprating ) + uprating = september_cpi(period) / september_cpi(data_year) # No State Pension is paid before State Pension age. A computed type # is already NONE there; this also holds if the type is an input. is_sp_age = person("is_SP_age", period) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py index e3dc980d29..f8f0778826 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -102,11 +102,11 @@ def formula(person, period, parameters): trading_tax = basic_rate * max_(0, profit - personal_allowance) trading_class_4 = ( person("ni_class_4_liable", period) - * class_4.rates.main + * class_4.contribution_scale.components[1].rate * max_( 0, - min_(profit, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, + min_(profit, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, ) ) # Reg. 14(2)(a), omitted from 6 April 2024: the Class 2 contributions diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index 6bca6f6b93..0227da876a 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -22,6 +22,7 @@ def formula(person, period, parameters): csp_replaces_ca = period.start.year >= 2025 receives_ca = person("carers_allowance_reported", period) > 0 ca = parameters(period).gov.dwp.carers_allowance + meets_age_condition = person("age", period) >= ca.min_age weekly_care_hours = person("care_hours", period) meets_work_condition = weekly_care_hours >= ca.min_hours would_claim = person("would_claim_carers_allowance", period) @@ -36,5 +37,6 @@ def formula(person, period, parameters): & (meets_work_condition | receives_ca) & ~gainfully_employed & would_claim + & meets_age_condition ) return eligible * ca.rate * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/dla/dla_m.py b/policyengine_uk/variables/gov/dwp/dla/dla_m.py index 742d4ea8b1..de0f768f4e 100644 --- a/policyengine_uk/variables/gov/dwp/dla/dla_m.py +++ b/policyengine_uk/variables/gov/dwp/dla/dla_m.py @@ -7,16 +7,21 @@ class dla_m(Variable): definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/73", + "https://www.legislation.gov.uk/ukpga/1992/7/section/73", + ) def formula(person, period, parameters): dla_m = parameters(period).gov.dwp.dla.mobility category = person("dla_m_category", period) categories = category.possible_values + age = person("age", period) return ( select( [ - category == categories.HIGHER, - category == categories.LOWER, + (category == categories.HIGHER) & (age >= dla_m.higher_min_age), + (category == categories.LOWER) & (age >= dla_m.lower_min_age), category == categories.NONE, ], [ diff --git a/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py b/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py index 4f82cef51c..eeb7ab10a2 100644 --- a/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py +++ b/policyengine_uk/variables/gov/dwp/esa_exempt_work_earnings.py @@ -100,13 +100,17 @@ def formula(person, period, parameters): tax_on_pay = max_(0, tax_on_pay - mca_reduction) nics = p.gov.hmrc.national_insurance class_1 = nics.class_1 - primary_threshold = class_1.thresholds.primary_threshold * WEEKS_IN_YEAR - upper_earnings_limit = class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR + primary_threshold = ( + class_1.employee_scale.components[1].threshold * WEEKS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) pay_above_upper_limit = max_(0, pay - upper_earnings_limit) class_1_on_pay = person("ni_liable", period) * ( - class_1.rates.employee.main + class_1.employee_scale.components[1].rate * (max_(0, pay - primary_threshold) - pay_above_upper_limit) - + class_1.rates.employee.additional * pay_above_upper_limit + + class_1.employee_scale.components[2].rate * pay_above_upper_limit ) pension_from_pay = ( employee_pension + personal_pension - personal_pension_from_profit @@ -135,11 +139,13 @@ def formula(person, period, parameters): class_4 = nics.class_4 class_4_band = max_( 0, - min_(profit, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, + min_(profit, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, ) notional_class_4 = ( - person("ni_class_4_liable", period) * class_4.rates.main * class_4_band + person("ni_class_4_liable", period) + * class_4.contribution_scale.components[1].rate + * class_4_band ) # Reg 99(3)(a), until 5 April 2024: Class 2 at the weekly rate unless # the profit is below the small profits threshold or, from 6 April diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 0f6d2d37fa..7b723bf4c6 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -23,5 +23,9 @@ class esa_income(Variable): unit = GBP def formula(benunit, period, parameters): - reported_award = add(benunit, period, ["esa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("esa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 031fa6c671..c071f77373 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -26,8 +26,9 @@ class esa_income_eligible(Variable): "in it, so when the claimant or partner reports, another member's " "report is paid or not with their claim. Whether that member is on " "the award themselves (is_on_income_related_esa) is tested on their " - "own claim. The award also ends once one of the family's legacy " - "benefits has closed (legacy_benefits_closed). This is not a full " + "own claim. Reports whose owner has closed legacy benefits are left " + "out before choosing candidates (person_legacy_benefits_closed). " + "This is not a full " "entitlement model." ) definition_period = YEAR @@ -43,7 +44,9 @@ def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income person = benunit.members capital = benunit("esa_income_assessable_capital", period) - reports_award = person("esa_income_reported", period) > 0 + reports_award = (person("esa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -65,11 +68,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~claimant_works & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-related ESA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= ESA.capital.limit) + return benunit.any(claimant) & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py index 684aa7a543..76424d1474 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py @@ -13,19 +13,14 @@ class housing_benefit_savings_credit_only_income(Variable): "authority takes the Secretary of State's assessment of net income for " "that award and modifies it only for the listed items. Modelled: the " "savings credit payable is added, and childcare charges and the " - "Housing Benefit earnings disregards are deducted. Pension Credit " - "income in the model disregards no earnings, whereas the Secretary of " - "State's figure is after the Pension Credit disregards (£20 for a lone " - "parent, £5 single or £10 couple otherwise). Deducting the Housing " - "Benefit disregards (£25 for a lone parent, £5 or £10 otherwise, and " - "the additional £17.10) therefore gives that figure less the higher " - "Housing Benefit amounts the regulation allows: £5 more for a lone " - "parent, and the additional disregard. This holds while neither " - "programme's £20 disregards for disabled people, carers and some " - "occupations are modelled; for those earners the Secretary of State's " - "figure is after £20, so the omission changes the income assessed, the " - "savings credit payable and possibly whether the award is savings " - "credit only. Childcare charges are deducted as in the general " + "higher Housing Benefit earnings disregards are deducted: a lone " + "parent's 25 pounds over the amount disregarded for Pension Credit, " + "and the additional 17.10 pounds. The Secretary of State's figure is " + "Pension Credit income, which is already after the Pension Credit " + "earnings disregards (20 pounds for a lone parent, carer, disabled " + "or blind earner, otherwise 5 pounds single or 10 pounds couple), so " + "Housing Benefit's own 5 and 10 pound amounts are not deducted again. " + "Childcare charges are deducted as in the general " "route, without the work condition or the cap at earnings. Housing " "Benefit's own income and tariff income rules do not apply to the " "Secretary of State's figure, which already counts Pension Credit " @@ -73,22 +68,35 @@ def formula(benunit, period, parameters): childcare = benunit( "housing_benefit_applicable_income_childcare_element", period ) - # Reg 27(4)(c)(i) (Sch 4 para 2: £25 for a lone parent, against SPC - # Regs 2002 Sch VI para 1's £20) and (d) (Sch 4 para 9(1), the - # additional £17.10; the para 5A exempt work disregard is not - # modelled). NI: reg 25(4)(c)(i) and (d), Sch 5 paras 2 and 9(1). - # The model's Pension Credit income applies no Sch VI disregard, so - # deducting the whole Housing Benefit disregard also takes off the Sch - # VI amounts already inside the Secretary of State's figure: £20 for a - # lone parent (para 1) and £5 or £10 otherwise (para 5), which match - # Sch 4 paras 2 and 7 but for the lone parent's extra £5. Neither - # schedule's £20 disregards (Sch VI paras 2 to 4; Sch 4 paras 3 to 5) - # are modelled. If Sch 4 paras 3 to 5 are added to the disregard - # variable, this route needs the Sch VI grounds instead, as reg 27(4) - # does not let Housing Benefit's own £20 grounds replace them. - # Reg 27(5): Housing Benefit's own income rules, including tariff - # income from capital, do not apply to the Secretary of State's figure. - disregard = benunit("housing_benefit_applicable_income_disregard", period) + # Reg 27(4)(c)(i) and (d) (NI: reg 25(4)(c)(i) and (d)): the + # Secretary of State's figure is after the SPC Regs 2002 Sch VI + # earnings disregards (pension_credit_earnings_disregard), so only the + # higher amounts Housing Benefit allows come off: a lone parent's + # Sch 4 para 2 disregard (25 pounds) over the amount disregarded for + # Pension Credit, and the additional disregard of Sch 4 para 9(1) + # (the para 5A exempt work disregard is not modelled). Housing + # Benefit's other grounds do not replace the Pension Credit ones. This + # route applies only under the pension-age Regulations, where the + # disregard is the standard amount (Sch 4 paras 2 and 7) and the + # additional amount (para 9), with no specified-accommodation amount. + p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard + net_earnings = benunit("housing_benefit_net_earnings", period) + lone_parent = benunit("is_lone_parent", period) + weekly_amount = select( + [lone_parent, benunit("is_couple", period)], + [p.lone_parent, p.couple], + default=p.single, + ) + standard = min_(weekly_amount * WEEKS_IN_YEAR, net_earnings) + additional = max_( + 0, benunit("housing_benefit_applicable_income_disregard", period) - standard + ) + higher_lone_parent = where( + lone_parent, + max_(0, standard - benunit("pension_credit_earnings_disregard", period)), + 0, + ) + disregard = higher_lone_parent + additional return max_( 0, pension_credit_income + savings_credit_payable - childcare - disregard, diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 8e91573713..1b701f64a5 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -25,10 +25,9 @@ class is_on_income_based_jsa(Variable): "positive, the report exceeding the tariff income from the benefit " "unit's capital, within the capital limit. On the formula path the " "claimant's or partner's claim and work never change this. Income-based " - "JSA must be open (gov.dwp.JSA.income.active), the family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "JSA must be open (gov.dwp.JSA.income.active), the legacy " + "benefits for this person's own award must remain open " + "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while jsa_income_eligible fails and " "jsa_income is not neutralised. On the formula path that nil comes only " "from the claimant's or partner's own claim failing the screen. " diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index 0df7276e1a..cd3c0173d5 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -24,10 +24,9 @@ class is_on_income_related_esa(Variable): "para 6(1)(e)), and the award on their own report alone is positive, " "the report exceeding the tariff income from the benefit unit's " "capital, within the capital limit. On the formula path the claimant's " - "or partner's claim and work never change this. The family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "or partner's claim and work never change this. The legacy " + "benefits for this person's own award must remain open " + "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while esa_income_eligible fails and " "esa_income is not neutralised. On the formula path that nil comes only " "from the claimant's or partner's own claim failing the screen. " diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index beae7d3f99..34a3c0e2a5 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -28,5 +28,9 @@ class jsa_income(Variable): def formula(benunit, period, parameters): if not parameters(period).gov.dwp.JSA.income.active: return benunit.empty_array() - reported_award = add(benunit, period, ["jsa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("jsa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_jsa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py index 505d1c47b1..8d26015795 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py @@ -31,8 +31,8 @@ class jsa_income_eligible(Variable): "reports, another member's report is paid or not with their claim. " "Whether that member is on the award themselves " "(is_on_income_based_jsa) is tested on their own claim. The award " - "also ends once one of the family's legacy benefits has closed " - "(legacy_benefits_closed). This is not a full entitlement model." + "excludes reports whose owner has closed legacy benefits before choosing " + "candidates (person_legacy_benefits_closed). This is not a full entitlement model." ) definition_period = YEAR reference = ( @@ -48,7 +48,9 @@ def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA person = benunit.members capital = benunit("jsa_income_assessable_capital", period) - reports_award = person("jsa_income_reported", period) > 0 + reports_award = (person("jsa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -67,11 +69,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~works_as_claimant & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-based JSA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= JSA.income.capital.limit) + return benunit.any(claimant) & (capital <= JSA.income.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py new file mode 100644 index 0000000000..3bbed54e3b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -0,0 +1,121 @@ +from policyengine_uk.model_api import * + + +class pension_credit_earnings_disregard(Variable): + label = "Pension Credit earnings disregard" + documentation = ( + "Earnings disregarded from Pension Credit income under Schedule VI to " + "the State Pension Credit Regulations 2002: 20 pounds a week for a " + "lone parent (para. 1), where the claimant or partner is a carer " + "satisfying Sch. I para. 4 (para. 3), or where the claimant or partner " + "receives a listed disability benefit (including working tax credit " + "with its disability or severe disability element) or is certified " + "blind (para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " + "pounds for a couple (para. 5). 20 pounds is the most disregarded " + "however many conditions are met (para. 4A). Reg. 17(9) disregards the " + "sums 'in calculating the claimant's earnings', which are net of income " + "tax and National Insurance (reg. 17(10)) and of half of pension " + "contributions (reg. 17A(4A)), so the disregard never exceeds the " + "claimant's and partner's net earnings, each source floored at nil. " + "Employed earnings (regs. 17(10) and 17A) are net of primary Class 1 " + "contributions and of the income tax payable on them " + "(pension_credit_employment_income_tax). Self-employed earnings (regs. " + "17(11) and 17B) are net of the notional tax and contributions of the " + "Computation of Earnings Regulations 1996 reg. 14 " + "(pension_credit_self_employment_deductions). Half of pension " + "contributions come off both. Not modelled: the employment-specific " + "20 pound " + "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " + "coastguards, lifeboat crew, reserve forces) and the transitional " + "protections of para. 4(2) to (4), and the continued carer " + "qualification of Sch. I para. 4 after carer's allowance stops (the " + "model has no record of when it stopped). The carer condition reads " + "is_entitled_to_carer_benefit, the carer addition's own test, so the " + "two always agree; the Carer's Allowance earnings limit is applied to " + "that test by PolicyEngine/policyengine-uk#2312." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + ) + + def formula(benunit, period, parameters): + pc = parameters(period).gov.dwp.pension_credit + p = pc.earnings_disregard + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + + on_disability_benefit = ( + add(person, period, p.higher.disability_benefits) > 0 + ) | person("is_blind", period) + unit_disability_benefits = p.higher.disability_benefit_unit_benefits + unit_on_disability_benefit = ( + add(benunit, period, unit_disability_benefits) > 0 + if len(unit_disability_benefits) > 0 + else False + ) + # Para. 4(1)(a)(vi): in receipt of working tax credit that includes + # its disability or severe disability element. + wtc_disability_element = ( + add( + benunit, + period, + ["WTC_disabled_element", "WTC_severely_disabled_element"], + ) + > 0 + ) & (benunit("working_tax_credit", period) > 0) + disabled = ( + benunit.any(claimant_or_partner & on_disability_benefit) + | unit_on_disability_benefit + | wtc_disability_element + ) + # Sch. I para. 4: a claimant or partner entitled to carer's allowance + # (the carer addition's own test), not caring hours alone. + carer = benunit.any( + claimant_or_partner & person("is_entitled_to_carer_benefit", period) + ) + lone_parent = benunit("is_lone_parent", period) + relation_type = benunit("relation_type", period) + weekly = where( + lone_parent | carer | disabled, + p.higher.amount, + p.standard[relation_type], + ) + + # Net earnings of the claimant and partner, each source floored at + # nil: a loss in one employment is not set against another (CER 1996 + # reg. 13(12), applied by SPC Regs 2002 reg. 17B). + # Employed earners (regs. 17(10) and 17A): less the income tax payable + # on the earnings and the primary Class 1 contributions deducted from + # them. Self-employed earners (regs. 17(11) and 17B): less the + # notional tax and contributions of CER 1996 regs. 13(4) and 14. Both: + # less half of pension contributions (reg. 17A(4A); CER 1996 reg. + # 13(4)(c)). + employment = max_(0, person("employment_income", period)) + self_employment = max_(0, person("self_employment_income", period)) + deductions = add( + person, + period, + ["ni_class_1_employee", "pension_credit_self_employment_deductions"], + ) + ( + person("pension_contributions", period) + * pc.income.pension_contributions_deduction + ) + # Each person's deductions come off their own earnings; the income tax + # on employed earnings is measured for the claimant and partner + # together (pension_credit_employment_income_tax). + own_net = max_(0, employment + self_employment - deductions) + net_earnings = max_( + 0, + benunit.sum(own_net * claimant_or_partner) + - benunit("pension_credit_employment_income_tax", period), + ) + return min_(weekly * WEEKS_IN_YEAR, net_earnings) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py new file mode 100644 index 0000000000..b922aafcb2 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -0,0 +1,128 @@ +from policyengine_uk.model_api import * + +DECISION = ( + "PolicyEngine does not model PAYE codes, so which part of a person's " + "income tax is payable on their earnings is a methodology choice, put to " + "Max on PolicyEngine/policyengine-uk#2019 and settled as option (a): treat " + "earnings as the top slice of the person's income. " +) + + +def income_tax_with_inputs_zeroed(simulation, period, variables): + """Each person's income tax with every person's value of each of + ``variables`` set to nil, on a branch that is dropped afterwards.""" + name = "pension_credit_without_" + "_and_".join(variables) + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + # Preserve every explicit stored input, including formula overrides + # supplied through set_input after the simulation was constructed. + branch.drop_computed_arrays() + people = branch.populations["person"] + for variable in variables: + branch.set_input(variable, period, np.zeros(people.count)) + return people("income_tax", period) + finally: + del simulation.branches[name] + + +class pension_credit_income_tax_without_employment_income(Variable): + value_type = float + entity = Person + label = "Income tax without employment income, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + income_tax = person("income_tax", period) + if not (person("employment_income", period) != 0).any(): + return income_tax + return income_tax_with_inputs_zeroed( + person.simulation, period, ["employment_income"] + ) + + +class pension_credit_income_tax_without_earnings(Variable): + value_type = float + entity = Person + label = "Income tax without earnings, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "or self-employment income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + without_employment = person( + "pension_credit_income_tax_without_employment_income", period + ) + if not (person("self_employment_income", period) != 0).any(): + return without_employment + return income_tax_with_inputs_zeroed( + person.simulation, + period, + ["employment_income", "self_employment_income"], + ) + + +class pension_credit_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax on employed earnings, for Pension Credit" + documentation = ( + "The income tax payable on the claimant's and partner's employed " + "earnings, which SPC Regs 2002 reg. 17(10) disregards in their net " + "amount: the fall in the claimant's and partner's income tax without " + "anyone's employment income, never negative. It includes any tax the " + "earnings add on savings and dividends (a lost starting-rate band or " + "personal savings allowance) and any effect of one partner's earnings " + "on the other's tax (through the marriage allowance), so Pension " + "Credit income does not change with either partner's earnings while " + "the disregard covers them, provided the zero floor on net earnings " + "does not limit the pension-contribution deduction. " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + ) + + def formula(benunit, period, parameters): + person = benunit.members + added = person("income_tax", period) - person( + "pension_credit_income_tax_without_employment_income", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) + + +class pension_credit_self_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax actually payable on self-employed profit, for Pension Credit" + documentation = ( + "The income tax the claimant's and partner's self-employment income " + "adds to their income tax, beneath any employment income: the fall in " + "their income tax without anyone's employment income when nobody has " + "self-employment income either, never negative. Pension Credit income " + "replaces it with the notional tax of the Computation of Earnings " + "Regulations 1996 reg. 14 (SPC Regs 2002 reg. 17(11)). " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17" + + def formula(benunit, period, parameters): + person = benunit.members + added = person( + "pension_credit_income_tax_without_employment_income", period + ) - person("pension_credit_income_tax_without_earnings", period) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index 944419416e..86e9504e9c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -10,6 +10,7 @@ class pension_credit_income(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/5", "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", ) documentation = ( "Income and deductions of the claimant and partner, together with " @@ -46,10 +47,26 @@ def formula(benunit, period, parameters): pension_contributions = benunit.sum( person("pension_contributions", period) * is_claimant_or_partner ) + # Self-employed earnings are net of the notional tax and contributions + # of the Computation of Earnings Regulations 1996 reg. 14, in place of + # those actually payable on the profit (reg. 17(11)). tax = benunit.sum( - add(person, period, ["income_tax", "national_insurance"]) + ( + add( + person, + period, + [ + "income_tax", + "national_insurance", + "pension_credit_self_employment_deductions", + ], + ) + - add(person, period, ["ni_class_2", "ni_class_4"]) + ) * is_claimant_or_partner - ) + ) - benunit("pension_credit_self_employment_income_tax", period) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate - return max_(0, total - deductions) + # Schedule VI earnings disregards (reg. 17(9)). + earnings_disregard = benunit("pension_credit_earnings_disregard", period) + return max_(0, total - deductions - earnings_disregard) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py new file mode 100644 index 0000000000..8ac7b852c4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -0,0 +1,82 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable + + +class pension_credit_self_employment_deductions(Variable): + value_type = float + entity = Person + label = "Notional tax and contributions on self-employed profit, for Pension Credit" + documentation = ( + "The income tax and social security contributions deducted from " + "self-employed earnings for Pension Credit (SPC Regs 2002 regs. 17(11) " + "and 17B; Computation of Earnings Regulations 1996 regs. 13(4) and " + "14), in place of those actually payable on the profit: a notional " + "income tax at the basic rate (the Scottish basic rate for a Scottish " + "taxpayer) on the profit less only the personal reliefs of ITA 2007 " + "Part 3 Chapters 2, 3 and 3A appropriate to their circumstances; " + "Class 4 contributions at the main rate on the profit between the " + "lower and upper profits limits, where Class 4 is payable at all; and " + "the Class 2 contributions payable, until reg. 14(2)(a) was omitted on " + "6 April 2024." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06", + "https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06", + ) + + def formula(person, period, parameters): + profit = max_(0, person("self_employment_income", period)) + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate is the band after the starter rate; read the + # band itself, since the scale at an instant leaves out the starter + # band before 2018-19. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + basic_rate = where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + rates.uk.rates[0], + ) + # Chapter 2 (personal and blind person's allowances) and Chapter 3A + # (marriage allowance, which the model deducts from income) relieve + # income; Chapter 3 (married couple's allowance) reduces the tax. + reliefs = add( + person, + period, + ["personal_allowance", "blind_persons_allowance", "marriage_allowance"], + ) + notional_tax = max_( + 0, + basic_rate * max_(0, profit - reliefs) + - person("married_couples_allowance_deduction", period), + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.contribution_scale.components[1].threshold + upper = class_4.contribution_scale.components[2].threshold + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.contribution_scale.components[1].rate + * min_(max_(0, profit - lower), upper - lower), + 0, + ) + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + # CER 14(2)(a), (3), before 6 April 2024: test this employment's + # chargeable income, not actual NI profits after trading allowance + # or other employment losses. SSCBA s.11 supplies the rate and + # age-liability rules. The compulsory rate is nil from 2024-25. + notional_class_2 = ( + person("ni_liable", period) + * class_2_liable(profit, class_2) + * class_2.flat_rate + * class_2_contribution_weeks(period.start.year) + ) + return where( + profit > 0, + notional_tax + notional_class_4 + notional_class_2, + 0, + ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py index 62b6eb3e2c..13c0c4b645 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py @@ -7,12 +7,19 @@ class savings_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/3", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", + ) documentation = ( - "The savings credit element of State Pension Credit, zero unless the " - "benefit unit meets the shared Pension Credit entitlement conditions. " - "This is the computed component before Pension Credit take-up; a " - "positive amount does not by itself establish an award in payment." + "The computed savings credit element of State Pension Credit, zero unless " + "the benefit unit meets the shared Pension Credit entitlement conditions. " + "An explicitly supplied input overrides the computed component. The " + "component is calculated before Pension Credit take-up, which applies " + "later to total Pension Credit entitlement; a positive component does " + "not by itself establish an award in payment." ) defined_for = "is_pension_credit_eligible" diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py index ec478db213..d94ce843dd 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py @@ -7,7 +7,9 @@ class is_uc_benefit_cap_exempt_earnings(Variable): label = "Exempt from the Universal Credit benefit cap because of earnings" documentation = ( "Whether the claimant's earned income, or a couple's combined earned " - "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a)). " + "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a), " + "uc_benefit_cap_earnings_exception_threshold), which follows the " + "national living wage each year. " "The measure is the assessed claimants' actual net earned income for " "Universal Credit: it includes statutory pay and deducts their own " "relievable pension contributions and tax and National Insurance on " @@ -22,22 +24,31 @@ class is_uc_benefit_cap_exempt_earnings(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2013/376/regulation/82", "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + "https://www.legislation.gov.uk/ukpga/2004/12/section/188", ) def formula(benunit, period, parameters): + # UC Regs 2013 reg. 82(1)(a) excepts an award where "the claimant's + # earned income or, if the claimant is a member of a couple, the + # couple's combined earned income" reaches 16 hours a week at the + # National Living Wage. A dependant's earnings do not count. + # + # Earned income is each claimant's own, after the reg. 55(5) and + # 57(2) deductions, so one partner's deductions never come off the + # other partner's earnings, tax on pension, property, savings or + # dividend income is not deducted, and contributions paid after 75 + # are not relievable (Finance Act 2004 s. 188(3)(a)). It is read + # before the minimum income floor and the work allowance, because + # reg. 82(4) excludes earnings imputed under reg. 62. + # + # Only earned income is read, not the Universal Credit award itself, + # to avoid a circular dependency with the cap. person = benunit.members - - # Earnings exemption for UC (£846/month = £10,152/year) - # Only check earned income, not the UC amount, to avoid a circular dependency. - # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, - # if the claimant is a member of a couple, the couple's combined - # earned income": a dependant's earnings do not count. claimant = person("is_uc_assessed_claimant", period) - # Reg. 82(4) excludes earnings imputed under the minimum income - # floor (reg. 62). Use actual UC earnings, before both that floor - # and the work allowance, with the reg. 55 and 57 deductions. uc_earned = benunit.sum( person("uc_individual_earned_income_before_mif", period) * claimant ) - earnings_threshold = 10_152 - return uc_earned >= earnings_threshold + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index 531c812ba6..ddbd0abac2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -52,8 +52,10 @@ def calculated_by(population, variable_name, period, model_variable): formula (``model_variable``'s), rather than one a reform put in its place. The tax-benefit system loads each variable file as its own module, so the formula is matched by where its code is, not by identity.""" + from policyengine_uk.scenarios.uc_reform import lcwra_formula_origin + variable = population.simulation.tax_benefit_system.get_variable(variable_name) - formula = variable.get_formula(period) + formula = lcwra_formula_origin(variable.get_formula(period), period) code = getattr(formula, "__code__", None) model = model_variable.formula.__code__ return code is not None and ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py new file mode 100644 index 0000000000..ec6b360596 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_earnings_exception_threshold(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit benefit cap earnings exception threshold" + documentation = ( + "The earned income over the year at or above which the Universal " + "Credit benefit cap does not apply (UC Regs 2013 reg. 82(1)(a)): " + "twelve times the monthly threshold. From 1 April 2017 the monthly " + "threshold is the pay for 16 hours a week at the national living " + "wage, the hourly rate in regulation 4 of the National Minimum Wage " + "Regulations, multiplied by 52 and divided by 12, with any fraction " + "of a pound disregarded (reg. 6(1A)(za)). Before then it was a fixed " + "£430 a month. gov.hmrc.minimum_wage.non_apprentice holds its April " + "2026 rate in later years, so the threshold stays at £10,572 a year " + "from 2026-27 until that parameter is projected, as the minimum " + "income floor does." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ), + dict( + title="Universal Credit Regulations 2013 reg. 6(1A)(za)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + ), + dict( + title="Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017", + href="https://www.legislation.gov.uk/uksi/2017/138/made", + ), + ] + + def formula(benunit, period, parameters): + exception = parameters( + period + ).gov.dwp.universal_credit.benefit_cap.earnings_exception + return exception.fixed_monthly_amount * MONTHS_IN_YEAR + + def formula_2017(benunit, period, parameters): + p = parameters(period) + exception = p.gov.dwp.universal_credit.benefit_cap.earnings_exception + # "The hourly rate set out in regulation 4" is the national living + # wage, the scale's top rate, as in uc_minimum_income_floor_gross. + national_living_wage = p.gov.hmrc.minimum_wage.non_apprentice.calc( + np.full(benunit.count, 200.0) + ) + monthly = np.floor( + national_living_wage * exception.hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + return monthly * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py index 483b5c7cd1..84dd97e376 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py @@ -30,6 +30,10 @@ def formula(benunit, period, parameters): # their actual Pension Credit calculation while breaking the cycle # through the renter's UC entitlement and pension-age route. simulation = benunit.simulation + # Income tax is independent of the UC housing element. Calculate + # both earnings counterfactuals on the parent so this branch and + # ordinary Pension Credit share them whichever is requested first. + benunit.members("pension_credit_income_tax_without_earnings", period) name = "uc_non_dep_pension_credit" while name in simulation.branches or name == simulation.branch_name: name += "_" diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py index 19025089f2..4d601ff00c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py @@ -35,11 +35,15 @@ def formula(person, period, parameters): # Primary Class 1 on pay equal to the threshold, with the annual # thresholds (weekly amounts times 52) the model uses for employees. class_1 = ni.class_1 - primary_threshold = class_1.thresholds.primary_threshold * WEEKS_IN_YEAR - upper_earnings_limit = class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR - employee = class_1.rates.employee.main * max_( + primary_threshold = ( + class_1.employee_scale.components[1].threshold * WEEKS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) + employee = class_1.employee_scale.components[1].rate * max_( 0, min_(threshold, upper_earnings_limit) - primary_threshold - ) + class_1.rates.employee.additional * max_( + ) + class_1.employee_scale.components[2].rate * max_( 0, threshold - upper_earnings_limit ) # Class 2 and Class 4 on profits equal to the threshold, by the same @@ -51,12 +55,12 @@ def formula(person, period, parameters): * class_2_contribution_weeks(period.start.year) ) class_4 = ni.class_4 - class_4_amount = class_4.rates.main * max_( + class_4_amount = class_4.contribution_scale.components[1].rate * max_( 0, - min_(threshold, class_4.thresholds.upper_profits_limit) - - class_4.thresholds.lower_profits_limit, - ) + class_4.rates.additional * max_( - 0, threshold - class_4.thresholds.upper_profits_limit + min_(threshold, class_4.contribution_scale.components[2].threshold) + - class_4.contribution_scale.components[1].threshold, + ) + class_4.contribution_scale.components[2].rate * max_( + 0, threshold - class_4.contribution_scale.components[2].threshold ) # Each class is due only from those the model treats as liable for # it, as for the person's actual contributions. Primary Class 1 and diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index f0abee8de3..a17fc4c791 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -10,7 +10,7 @@ class uc_individual_earned_income_before_mif(Variable): documentation = ( "The person's actual earned income for Universal Credit: their " "earnings, including statutory sick, maternity and paternity pay, " - "less their own relievable pension contributions and their own " + "less allowable employment expenses, their own relievable pension contributions and their own " "income tax and National Insurance in respect of their employment " "and self-employment. Concurrent trade results include company " "income treated as self-employed earnings under regulation 77; " @@ -24,9 +24,13 @@ class uc_individual_earned_income_before_mif(Variable): unit = GBP reference = [ dict( - title="Universal Credit Regulations 2013 reg. 55(4) and (5)", + title="Universal Credit Regulations 2013 reg. 55(3)(a), (4) and (5)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", ), + dict( + title="Universal Credit Regulations (Northern Ireland) 2016 reg. 55(3)(a)", + href="https://www.legislation.gov.uk/nisr/2016/216/regulation/55", + ), dict( title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", @@ -92,8 +96,12 @@ def formula(person, period, parameters): + person("uc_national_insurance_on_earnings", period) - person("uc_national_insurance_on_trade", period) ) + # Reg. 55(3)(a) disregards expenses allowed under Chapter 2 of + # Part 5 ITEPA. This input is the already-allowable amount, after + # relevant-employment limits, not a claim for arbitrary raw costs. employed = ( add(person, period, earnings_components) + - person("employment_expenses", period) - employment_tax_and_national_insurance ) # Contributions paid after the person reaches 75 are not relievable diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py index fec4ccaa0d..51e8d1889f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -1,13 +1,52 @@ from policyengine_uk.model_api import * +def legacy_award_is_closed(reports, pension_age_route, mixed_age_couple, dwp): + """Existing annual migration proxy for one award's owners. + + This preserves the model's programme closure dates and Pension Credit + exceptions. It does not simulate notices, deadlines or run-on periods. + """ + on_pension_credit = reports("pension_credit_reported") + # A tax credit closure notice sent to Pension Credit, not Universal + # Credit, a family on Pension Credit, or one on Child Tax Credit but + # not Working Tax Credit that meets the Pension Credit age conditions + # (single, both pension age, or a protected mixed-age couple; SI + # 2019/167 art. 3A(1)). Every other tax credit family, including a + # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a + # Universal Credit migration notice (reg 44). + pension_credit_route = on_pension_credit | ( + pension_age_route & ~reports("working_tax_credit_reported") + ) + tax_credits_closed = ( + reports("child_tax_credit_reported", "working_tax_credit_reported") + & (not dwp.tax_credits.active) + & ~pension_credit_route + ) + # Income Support and income-based JSA are working-age benefits; the + # rules ending legacy awards on a Universal Credit claim do not apply + # to a single claimant, or joint claimants both, over the qualifying + # age for State Pension Credit (reg 8(2B)). + wholly_pension_age = pension_age_route & ~mixed_age_couple + income_support_closed = reports("income_support_reported") & ( + not dwp.income_support.active + ) + jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) + return tax_credits_closed | ( + (income_support_closed | jsa_income_closed) + & ~on_pension_credit + & ~wholly_pension_age + ) + + class legacy_benefits_closed(Variable): value_type = bool entity = BenUnit label = "Moved off legacy benefits onto the Universal Credit route" documentation = ( - "Whether DWP has moved this family off its legacy means-tested " - "benefits towards Universal Credit because one it reports has closed: " + "Annual proxy for whether DWP has moved the claimant and partner off " + "their legacy means-tested " + "benefits towards Universal Credit because one they report has closed: " "tax credits from 6 April 2025, and Income Support and income-based " "Jobseeker's Allowance from 1 April 2026 (their active parameters). " "The migration notice covers all the family's legacy awards, so they " @@ -19,7 +58,9 @@ class legacy_benefits_closed(Variable): "Tax Credit are included; Income Support and income-based JSA " "closures do not move a wholly pension-age family. Housing Benefit and income-related " "Employment and Support Allowance join this list when the model ends " - "their working-age awards." + "their working-age awards. Reports by other benefit-unit members do " + "not close or shield this award. This annual proxy does not model " + "actual notices, deadlines, run-on periods or Housing Benefit exceptions." ) definition_period = YEAR reference = ( @@ -33,39 +74,16 @@ class legacy_benefits_closed(Variable): ) def formula(benunit, period, parameters): - dwp = parameters(period).gov.dwp + person = benunit.members + owners = person("is_claimant_or_partner", period) def reports(*variables): - return add(benunit, period, list(variables)) > 0 + amounts = sum(person(name, period) for name in variables) + return benunit.sum(amounts * owners) > 0 - on_pension_credit = reports("pension_credit_reported") - pension_age_route = benunit("meets_pension_credit_age_conditions", period) - # A tax credit closure notice sent to Pension Credit, not Universal - # Credit, a family on Pension Credit, or one on Child Tax Credit but - # not Working Tax Credit that meets the Pension Credit age conditions - # (single, both pension age, or a protected mixed-age couple; SI - # 2019/167 art. 3A(1)). Every other tax credit family, including a - # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a - # Universal Credit migration notice (reg 44). - pension_credit_route = on_pension_credit | ( - pension_age_route & ~reports("working_tax_credit_reported") - ) - tax_credits_closed = ( - reports("child_tax_credit_reported", "working_tax_credit_reported") - & (not dwp.tax_credits.active) - & ~pension_credit_route - ) - # Income Support and income-based JSA are working-age benefits; the - # rules ending legacy awards on a Universal Credit claim do not apply - # to a single claimant, or joint claimants both, over the qualifying - # age for State Pension Credit (reg 8(2B)). - wholly_pension_age = pension_age_route & ~benunit("is_mixed_age_couple", period) - income_support_closed = reports("income_support_reported") & ( - not dwp.income_support.active - ) - jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) - return tax_credits_closed | ( - (income_support_closed | jsa_income_closed) - & ~on_pension_credit - & ~wholly_pension_age + return legacy_award_is_closed( + reports, + benunit("meets_pension_credit_age_conditions", period), + benunit("is_mixed_age_couple", period), + parameters(period).gov.dwp, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py new file mode 100644 index 0000000000..9276abf287 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.legacy_benefits_closed import ( + legacy_award_is_closed, +) + + +class person_legacy_benefits_closed(Variable): + value_type = bool + entity = Person + label = "Legacy benefits closed for this person's award" + documentation = ( + "The existing annual legacy-closure proxy, scoped to the award owner. " + "The claimant and partner share legacy_benefits_closed. Other members " + "claiming in their own right use only their own reports and Pension " + "Credit qualifying age. Their reports neither close nor shield the " + "couple's award, and the couple's reports do not close theirs. No new " + "notice, deadline, run-on or legal relationship inference is made." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + ) + + def formula(person, period, parameters): + def reports(*variables): + return sum(person(name, period) for name in variables) > 0 + + pension_age = person("has_attained_state_pension_credit_qualifying_age", period) + own_closed = legacy_award_is_closed( + reports, pension_age, pension_age & False, parameters(period).gov.dwp + ) + return where( + person("is_claimant_or_partner", period), + person.benunit("legacy_benefits_closed", period), + own_closed, + ) diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index 31a414c0fb..3a6d276474 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -1,3 +1,5 @@ +import numpy as np + from policyengine_uk.model_api import * from policyengine_uk.utils.capital_gains import badr_gains_before_response @@ -15,6 +17,12 @@ def _take(amount, band_left): return inside, above, band_left - inside +# Each pass of the exemption allocation ends where a schedule's gains cross a +# band boundary, so a handful is enough; four schedules in three bands give at +# most twelve boundaries. +_MAX_EXEMPTION_PASSES = 16 + + class capital_gains_tax(Variable): label = "capital gains tax" documentation = ( @@ -29,8 +37,8 @@ class capital_gains_tax(Variable): "other two in proportion with the rest of the gains. All three are " "clipped so that together they never exceed capital_gains. The annual " "exempt amount is set " - "against the schedule with the highest top rate first (TCGA 1992 " - "s. 1K(5)); the unused basic rate band goes to relief gains first, as " + "against the gains where it saves most tax, pound by pound " + "(TCGA 1992 s. 1K(5)); the unused basic rate band goes to relief gains first, as " "s. 1I(4)-(6) requires, and then to the schedule with the widest gap " "between its higher and basic rates (s. 1I(7)-(9)). Gains above the " "basic rate band are split at the income tax higher rate limit only so " @@ -68,7 +76,13 @@ def formula(person, period, parameters): basic_rate_limit = it.rates.uk.thresholds[1] + basic_rate_band_extension remaining_basic_rate_band = max_(basic_rate_limit - taxable_income, 0) higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension - higher_rate_band = higher_rate_limit - basic_rate_limit + # Gains are the top slice of income (TCGA 1992 s. 1H), so the gains + # above the basic rate band start at the basic rate limit or at + # taxable income, whichever is higher. Only the rest of the band up to + # the higher rate limit is left for them. + remaining_higher_rate_band = max_( + higher_rate_limit - max_(taxable_income, basic_rate_limit), 0 + ) # The schedule inputs describe gains before any realisation response. # Relief gains move by their own response, which has its own @@ -132,19 +146,6 @@ def component(amount): "main": main, "badr": badr, } - top_rate = {name: rates[name].higher_rate for name in rates} - top_rate["badr"] = cgt.badr.rate - - # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most - # beneficial to the taxpayer, which is against the highest top rate - # first. Relief gains carry the lowest rate and come last. - after_aea = {} - aea_left = aea - for name in sorted(amounts, key=lambda name: -top_rate[name]): - used = min_(amounts[name], aea_left) - after_aea[name] = amounts[name] - used - aea_left = aea_left - used - # Basic rate band. Relief gains take it first, TCGA 1992 s. 1I(4)-(6). # The taxpayer then allocates the rest, s. 1I(7)-(9), which saves most # on the schedule with the widest higher-minus-basic gap. Carried @@ -154,26 +155,118 @@ def component(amount): rates, key=lambda name: -(rates[name].higher_rate - rates[name].basic_rate), ) - inside_basic, above_basic = {}, {} - band_left = remaining_basic_rate_band - for name in basic_order: - inside_basic[name], above_basic[name], band_left = _take( - after_aea[name], band_left - ) - - # Gains above the basic rate band are split at the higher rate limit - # so reforms can charge an additional rate; in law the two rates are - # equal. Relief gains are the lowest part of the gains here too. + # Gains above the basic rate band are split at the higher rate limit, + # stacked on top of taxable income, so reforms can charge an + # additional rate; in law the two rates are equal. Relief gains are + # the lowest part of the gains here too. higher_order = ["badr"] + sorted( rates, key=lambda name: -(rates[name].additional_rate - rates[name].higher_rate), ) - inside_higher, above_higher = {}, {} - band_left = higher_rate_band - for name in higher_order: - inside_higher[name], above_higher[name], band_left = _take( - above_basic[name], band_left - ) + + def allocate_bands(schedule_amounts): + inside_basic, above_basic = {}, {} + band_left = remaining_basic_rate_band + for name in basic_order: + inside_basic[name], above_basic[name], band_left = _take( + schedule_amounts[name], band_left + ) + inside_higher, above_higher = {}, {} + band_left = remaining_higher_rate_band + for name in higher_order: + inside_higher[name], above_higher[name], band_left = _take( + above_basic[name], band_left + ) + return inside_basic, inside_higher, above_higher + + # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most + # beneficial to the taxpayer. Each pound saves the tax it would have + # borne at the margin, which depends on the schedule it comes off and, + # where removing it frees band for another schedule, on that schedule + # too. So the exemption is set greedily: in each pass, against the + # schedule where a pound saves most, up to the point where any + # schedule's gains cross a band boundary and the saving can change. + # Ties keep declaration order. Under current law, where the higher and + # additional rates are equal, this gives the same allocation as + # ranking the schedules by rate, apart from float rounding where the + # exemption is split across schedules. + names = list(amounts) + flat_rate = {"badr": cgt.badr.rate} + + def slices(schedule_amounts): + inside_basic, inside_higher, above_higher = allocate_bands(schedule_amounts) + result = [] + for name in names: + if name in flat_rate: + band_rates = [flat_rate[name]] * 3 + else: + band_rates = [ + rates[name].basic_rate, + rates[name].higher_rate, + rates[name].additional_rate, + ] + parts = [inside_basic[name], inside_higher[name], above_higher[name]] + result += list(zip(parts, band_rates)) + return result + + left = {name: amounts[name].astype(float) for name in names} + used = {name: np.zeros_like(left[name]) for name in names} + aea_left = np.full_like(left["main"], aea) + step = 1e-6 + for _ in range(_MAX_EXEMPTION_PASSES): + if not np.any(aea_left > 0): + break + base = slices(left) + savings, room = [], [] + for name in names: + nudged = dict(left) + nudged[name] = left[name] - step + saving = np.zeros_like(aea_left) + distance = np.full_like(aea_left, np.inf) + for (part, rate), (moved, _) in zip(base, slices(nudged)): + slope = np.round((moved - part) / step) + saving = saving - slope * rate + shrinking = (slope < 0) & (part > 0) + distance = where( + shrinking, + min_(distance, part / where(shrinking, -slope, 1)), + distance, + ) + active = (left[name] > 0) & (aea_left > 0) + savings.append(where(active, np.round(saving, 9), -np.inf)) + room.append(min_(left[name], distance)) + savings = np.stack(savings) + best = np.argmax(savings, axis=0) + can_take = np.max(savings, axis=0) > -np.inf + for i, name in enumerate(names): + taken = where(can_take & (best == i), min_(aea_left, room[i]), 0) + left[name] = left[name] - taken + used[name] = used[name] + taken + aea_left = aea_left - taken + # Any exemption left after the passes, which only happens when gains + # cross more band boundaries than there are passes, goes in + # declaration order. + for name in names: + taken = min_(aea_left, left[name]) + used[name] = used[name] + taken + aea_left = aea_left - taken + # Where the whole exemption went against one schedule, deduct it in a + # single step so that the passes add no rounding of their own. + schedules_used = sum((used[name] > 0).astype(int) for name in names) + after_aea = { + name: where( + schedules_used <= 1, + where( + used[name] > 0, + amounts[name] - min_(amounts[name], aea), + amounts[name], + ), + amounts[name] - used[name], + ).astype(amounts[name].dtype) + for name in names + } + + inside_basic, inside_higher, above_higher = allocate_bands(after_aea) def schedule_tax(name): schedule = rates[name] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py index aa3cd8ecf3..4c1e3f25db 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py @@ -1,19 +1,5 @@ from policyengine_uk.model_api import * - - -def _tapered_allowance(person, period, allowance, reduction_rate): - adjusted = person("pension_annual_allowance_adjusted_income", period) - threshold = person("pension_annual_allowance_threshold_income", period) - # s. 228ZA(3)(b) derives the threshold limit from the standard allowance. - high_income = (adjusted > allowance.taper) & ( - threshold > allowance.taper - allowance.default - ) - reduction = np.floor(max_(0, adjusted - allowance.taper) * reduction_rate) - return where( - high_income, - max_(allowance.minimum, allowance.default - reduction), - allowance.default, - ) +from policyengine_uk.utils.pensions import annual_allowance_for_year class pension_annual_allowance(Variable): @@ -37,12 +23,9 @@ def formula(person, period, parameters): ).gov.hmrc.income_tax.allowances.annual_allowance.default def formula_2016(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - # Original s. 228ZA(1): (A - £10,000) / £60,000, equal to one half - # at the statutory £40,000 standard allowance. - rate = (allowance.default - allowance.minimum) / allowance.taper_income_range - return _tapered_allowance(person, period, allowance, rate) - - def formula_2020(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - return _tapered_allowance(person, period, allowance, allowance.reduction_rate) + return annual_allowance_for_year( + person("pension_annual_allowance_adjusted_income", period), + person("pension_annual_allowance_threshold_income", period), + parameters(period).gov.hmrc.income_tax.allowances.annual_allowance, + period.start.year, + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 62f4794ba6..73f78c28d5 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -33,10 +33,10 @@ def formula(person, period, parameters): for index, threshold in enumerate(rates.uk.thresholds) ] basic = allowances + limits[0] - higher = allowances + limits[-2] - add = allowances + limits[-1] + higher = allowances + limits[1] + add = allowances + limits[2] return select( - [ani >= add, ani >= higher, ani > basic], + [ani > add, ani > higher, ani > basic], [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], default=TaxBand.NONE, ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py index cffac78c1b..77b4b95b23 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py @@ -4,10 +4,26 @@ class employment_expenses(Variable): value_type = float entity = Person - label = "Cost of expenses necessarily incurred and reimbursed by employment" - definition_period = YEAR - reference = dict( - title="Income Tax Act (Earnings and Pensions) Act 2003 s. 333", - href="https://www.legislation.gov.uk/ukpga/2003/1/section/333", + label = "Allowable employment expenses" + documentation = ( + "Employment expenses allowed as a deduction under Chapter 2 of Part 5 " + "of ITEPA, after the applicable relevant-employment and other deduction " + "limits. Supply the legally allowed amount, not all raw work costs. " + "Amounts paid on the employee's behalf, and reimbursements, qualify " + "only to the extent required by sections 333 and 334, including their " + "inclusion in earnings. Do not deduct the same costs twice. The model " + "does not determine admissibility or per-employment limits from this " + "person-level total." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 333 and 334", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2", + ), + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 328 to 330", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/1", + ), + ] unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 959562f123..0d4f98dd91 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import ( + pension_contributions_relief_amount, + relevant_uk_earnings, + trading_profits_before_losses, +) class adjusted_total_income(Variable): @@ -11,10 +16,13 @@ class adjusted_total_income(Variable): "components, with losses brought forward (loss_relief, a Step 2 " "relief the model deducts from trading profits) added back. Steps 3 " "and 4 deduct the pension contributions given relief (FA 2004 " - "ss.192-194): the person's own contributions up to the greater of " - "their relevant UK earnings (s.189(2): taxable employment income, " - "including benefits and statutory pay, and trading profits) and the " - "basic amount (s.190), none from " + "ss.192-194): the person's own contributions up to relevant UK " + "earnings (s.189(2): employment income, including statutory pay, " + "less allowable expenses but before pension deductions, and trading " + "profits before losses). The basic amount can " + "increase that limit only through relief at source (ss.190(3), " + "191(7)), which the model represents with gross personal pension " + "contributions; employee contributions represent net pay. None is relieved from " "age 75 (s.188(3)(a)). The annual allowance limits tax-relieved " "saving through a charge, not the relief itself, so it is not applied " "here. Step 2 adds back payroll giving, which the model does not have." @@ -30,40 +38,28 @@ class adjusted_total_income(Variable): title="Finance Act 2004 ss. 188-190", href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", ), + dict( + title="Finance Act 2004 s. 191(7)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/191", + ), ] def formula(person, period, parameters): - p = parameters(period).gov.hmrc - profits = person("self_employment_income", period) - other_deductions = [ - deduction - for deduction in p.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = max_( - 0, profits - add(person, period, other_deductions) - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + hmrc = parameters(period).gov.hmrc + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) total_income = ( person("net_income_before_trade_loss_relief", period) + losses_brought_forward_deducted ) - # Relevant UK earnings (FA 2004 s.189(2)): employment income chargeable - # to tax, including taxable benefits and statutory payments, and - # trading profits chargeable to tax. - relevant_earnings = ( - person("taxable_employment_income", period) + profits_before_carry_forward - ) - relief_limit = max_( - p.income_tax.reliefs.pension_contribution.basic_amount, relevant_earnings - ) - under_age_limit = ( - person("age", period) < p.pensions.pension_contributions_relief_age_limit - ) - relieved_contributions = ( - min_(person("pension_contributions", period), relief_limit) - * under_age_limit + # The same relief as pension_contributions_relief, on the same + # relevant UK earnings (FA 2004 ss.189-191). + relieved_contributions = pension_contributions_relief_amount( + person("pension_contributions", period), + relevant_uk_earnings(person, period, hmrc), + person("personal_pension_contributions", period), + person("age", period), + hmrc, ) return max_(0, total_income - relieved_contributions) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py index 6f8dffd2c2..76d002631b 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py @@ -13,7 +13,26 @@ def formula(person, period, parameters): income = person("ni_class_1_income", period) parameters = parameters(period).gov.hmrc.national_insurance.class_1 upper_earnings_limit = ( - parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[2].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) - return parameters.rates.employee.additional * upper_earnings_limit_income + ordinary = ( + parameters.employee_scale.components[2].rate * upper_earnings_limit_income + ) + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # Match the annual primary component; this is not an interim payroll charge. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_additional = scale.rates[2] * max_( + annual_income - scale.thresholds[2], 0 + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_additional / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py index 69b5db4986..8464b3db3f 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py @@ -15,10 +15,14 @@ def formula(person, period, parameters): # Thresholds are weekly, so multiply by weeks in year and divide by months in year primary_threshold = ( - parameters.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[1].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit = ( - parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + parameters.employee_scale.components[2].threshold + * WEEKS_IN_YEAR + / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) @@ -26,4 +30,22 @@ def formula(person, period, parameters): max_(income - primary_threshold, 0) - upper_earnings_limit_income ) - return parameters.rates.employee.main * primary_threshold_income + ordinary = ( + parameters.employee_scale.components[1].rate * primary_threshold_income + ) + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # These are annual earnings-period amounts, allocated evenly to model months. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_primary = scale.rates[1] * min_( + max_(annual_income - scale.thresholds[1], 0), + scale.thresholds[2] - scale.thresholds[1], + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_primary / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py index a179befa83..c4ad894ea7 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py @@ -22,9 +22,11 @@ def formula(person, period, parameters): taxed_earnings = earnings + added_pension_contributions else: taxed_earnings = earnings - secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + secondary_threshold = ( + class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR + ) main_earnings = max_( taxed_earnings - secondary_threshold, 0, ) - return class_1.rates.employer * main_earnings + return class_1.employer_scale.components[1].rate * main_earnings diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py index a54fc2da39..184c12e3a9 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py @@ -10,6 +10,7 @@ class ni_class_1_income(Variable): unit = GBP adds = [ "employment_income", + "salary_sacrifice_pension_ni_excess", "statutory_sick_pay", "statutory_maternity_pay", "statutory_paternity_pay", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py index 0657f27c97..eba6649452 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py @@ -16,19 +16,19 @@ def formula(person, period, parameters): # the para 3 loss reliefs; Class 1 contributions are not a deduction. profits = person("ni_class_4_profits", period) add_rate_income = max_( - profits - class_4.thresholds.upper_profits_limit, + profits - class_4.contribution_scale.components[2].threshold, 0, ) main_rate_income = ( max_( - profits - class_4.thresholds.lower_profits_limit, + profits - class_4.contribution_scale.components[1].threshold, 0, ) - add_rate_income ) pre_maximum_amount = ( - main_rate_income * class_4.rates.main - + add_rate_income * class_4.rates.additional + main_rate_income * class_4.contribution_scale.components[1].rate + + add_rate_income * class_4.contribution_scale.components[2].rate ) maximum_amount = person("ni_class_4_maximum", period) # Regulation 100(1): the annual maximum applies only where primary diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py index 8ba64c53b3..98c4a91e0d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py @@ -14,14 +14,14 @@ def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 profits = person("ni_class_4_profits", period) add_rate_income = max_( - profits - class_4.thresholds.upper_profits_limit, + profits - class_4.contribution_scale.components[2].threshold, 0, ) main_rate_income = ( max_( - profits - class_4.thresholds.lower_profits_limit, + profits - class_4.contribution_scale.components[1].threshold, 0, ) - add_rate_income ) - return main_rate_income * class_4.rates.main + return main_rate_income * class_4.contribution_scale.components[1].rate diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py index f599002047..69128f473b 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py @@ -11,8 +11,8 @@ class ni_class_4_maximum(Variable): def formula(person, period, parameters): ni = parameters(period).gov.hmrc.national_insurance - upl = ni.class_4.thresholds.upper_profits_limit - lpl = ni.class_4.thresholds.lower_profits_limit + upl = ni.class_4.contribution_scale.components[2].threshold + lpl = ni.class_4.contribution_scale.components[1].threshold step_1 = upl - lpl # Regulation 100 writes its percentages out, but in every year from # 2015-16 they match the Class 4 rates: 9% and 2% (Steps Two, Five, @@ -22,8 +22,8 @@ def formula(person, period, parameters): # Insurance Contributions (Reduction in Rates) Act 2024 s 2(3)) with # Steps Eight and Nine still at 2%. So the steps read the rate # parameters. - main_rate = ni.class_4.rates.main - add_rate = ni.class_4.rates.additional + main_rate = ni.class_4.contribution_scale.components[1].rate + add_rate = ni.class_4.contribution_scale.components[2].rate step_2 = step_1 * main_rate # Class 2 contributions drop out of regulation 100 from 6 April 2024. includes_class_2 = ni.class_4.annual_maximum.includes_class_2 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index ba71d85791..661c99bce2 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employee(Variable): documentation = ( "Additional employee National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to NI (but gets income tax relief)." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting approximation is separate " + "from the actual Class 1 contribution curves and liability flags." ) entity = Person definition_period = YEAR @@ -16,21 +17,22 @@ class salary_sacrifice_pension_ni_employee(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 parameters ni_params = parameters(period).gov.hmrc.national_insurance.class_1 employment_income = person("employment_income", period) - upper_earnings_limit = ni_params.thresholds.upper_earnings_limit * WEEKS_IN_YEAR + upper_earnings_limit = ( + ni_params.employee_scale.components[2].threshold * WEEKS_IN_YEAR + ) # Apply appropriate NI rate based on income level # Main rate (8%) for income <= UEL, additional rate (2%) for income > UEL ni_rate = where( employment_income <= upper_earnings_limit, - ni_params.rates.employee.main, - ni_params.rates.employee.additional, + ni_params.employee_scale.components[1].rate, + ni_params.employee_scale.components[2].rate, ) return excess * ni_rate diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..43593e344e 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employer(Variable): documentation = ( "Additional employer National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to employer NI." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting measure is separate from " + "the actual Class 1 contribution curve and liability flags." ) entity = Person definition_period = YEAR @@ -16,12 +17,11 @@ class salary_sacrifice_pension_ni_employer(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to employer NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 employer rate parameter ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.rates.employer + employer_rate = ni_params.employer_scale.components[1].rate return excess * employer_rate diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py new file mode 100644 index 0000000000..452ebf4841 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class salary_sacrifice_pension_ni_excess(Variable): + label = "Salary sacrifice pension contributions subject to NICs" + documentation = ( + "Employer pension contributions through salary sacrifice above the NICs " + "exemption cap. This is added only to Class 1 earnings: it is neither " + "cash employment income nor an actual member pension contribution. " + "The existing annual model samples the 2029-04-06 cap; payroll interval " + "rules and individual transaction dates are not modelled." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2026/15/section/1/enacted" + + def formula(person, period, parameters): + sacrifice = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + return max_(sacrifice - cap, 0) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py new file mode 100644 index 0000000000..73ac4b743c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import workplace_pension_contributions + + +class defined_benefit_pension_contributions(Variable): + value_type = float + entity = Person + label = "Workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The defined_benefit_pension_share of the person's workplace pension " + "contributions: employer contributions, actual employee " + "contributions under a net pay arrangement and full salary " + "sacrifice. These contributions are not a pension input amount: the " + "annual allowance counts defined_benefit_pension_input_amount for " + "them instead." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ) + + def formula(person, period, parameters): + share = clip(person("defined_benefit_pension_share", period), 0, 1) + return share * workplace_pension_contributions(person, period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py new file mode 100644 index 0000000000..01101fbff7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_input_amount(Variable): + value_type = float + entity = Person + label = "Pension input amount under defined-benefit arrangements" + documentation = ( + "The increase over the pension input period in the value of the " + "individual's rights under defined-benefit arrangements (FA 2004 " + "s. 234): 16 times the annual pension, plus any separate lump sum, at " + "the end of the period less the same at its start, with the start " + "value raised by CPI (s. 235), and nil if negative. Supply it where " + "known, as on a scheme's pension savings statement, together with " + "defined_benefit_pension_share for the workplace contributions that " + "fund it: contributions outside that share are counted as money " + "purchase input as well. Otherwise it is " + "defined_benefit_pension_contributions times " + "gov.simulation.pensions.defined_benefit_input_per_contribution." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + dict( + title="Finance Act 2004 s. 235", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/235", + ), + dict( + title="HMRC Pensions Tax Manual PTM053301", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm053301", + ), + ] + + def formula(person, period, parameters): + ratio = parameters( + period + ).gov.simulation.pensions.defined_benefit_input_per_contribution + return ratio * person("defined_benefit_pension_contributions", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py index 4d84d8c253..2d2a3ab5fe 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -13,8 +13,8 @@ class pension_annual_allowance_adjusted_income(Variable): "and exclude taxable lump-sum death benefits already included in " "income. The pension input amount can be supplied to include statutory " "defined-benefit accrual; its default is a cash-contribution proxy. " - "The NICs-only returned salary sacrifice is removed from both model " - "income and member contributions. All actual own contributions are " + "The NICs-only excess enters neither model income nor member " + "contributions. All actual own contributions are " "subtracted, including unrelievable contributions from age 75." ) definition_period = YEAR @@ -22,9 +22,8 @@ class pension_annual_allowance_adjusted_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/4" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_income = annual_allowance_net_income(person, period, parameters) - actual_member_contributions = person("pension_contributions", period) - returned + actual_member_contributions = person("pension_contributions", period) return ( actual_income + person("pension_contributions_for_annual_allowance", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index 374353361c..1bcdba74a7 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -1,9 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import ( - annual_allowance_net_income, - pension_contributions_relief_amount, - relevant_uk_earnings, -) +from policyengine_uk.utils.pensions import annual_allowance_net_income class pension_annual_allowance_threshold_income(Variable): @@ -11,9 +7,9 @@ class pension_annual_allowance_threshold_income(Variable): entity = Person label = "Threshold income for the pension annual allowance" documentation = ( - "FA 2004 s. 228ZA(5) threshold income. Remove the NICs-only returned " - "salary sacrifice and recompute income-dependent Step 2 relief on " - "actual member contributions and relevant earnings. This combines " + "FA 2004 s. 228ZA(5) threshold income. Use actual pay and member " + "contributions, retaining income-dependent Step 2 relief ordering. " + "The NICs-only excess is not ordinary income. This combines " "the net-pay deduction from statutory net income with gross RAS " "relief, including the basic amount and age limit. Add back full " "salary sacrifice before the NICs cap, except the supplied amount " @@ -26,7 +22,6 @@ class pension_annual_allowance_threshold_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/5" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_sacrifice = person( "pension_contributions_via_salary_sacrifice_from_pay", period ) @@ -34,25 +29,9 @@ def formula(person, period, parameters): 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) ) relevant_sacrifice = max_(0, actual_sacrifice - grandfathered) - # NICs reclassification changes neither income-tax earnings nor the - # actual member contributions. Its relief need not equal the returned - # amount: after 75 there is none, and low earnings can use basic RAS. - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned - relief = where( - returned > 0, - pension_contributions_relief_amount( - actual_member_contributions, - actual_earnings, - person("personal_pension_contributions", period), - person("age", period), - parameters(period).gov.hmrc, - ), - person("pension_contributions_relief", period), - ) return ( annual_allowance_net_income(person, period, parameters) - - relief + - person("pension_contributions_relief", period) + relevant_sacrifice - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 9463d75e7f..f300757607 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -6,21 +6,31 @@ class pension_contributions_for_annual_allowance(Variable): entity = Person label = "Total pension contributions counting toward the Annual Allowance" documentation = ( - "The default DC pension input amount under FA 2004 s. 233: member " + "The total pension input amount (FA 2004 s. 229). For money purchase " + "arrangements it is the input amount under s. 233: member " "contributions made before age 75, employer contributions and full " "salary sacrifice. Member contributions from age 75 are unrelievable " "under s. 188(3)(a) and excluded, while employer contributions still " "count. Contributions before 75 above the earnings relief limit " - "still count. Supply the statutory total pension input amount, " - "including valued defined-benefit accrual, to override this cash " - "proxy. The same input feeds adjusted income and the AA charge." + "still count. The defined_benefit_pension_share of workplace " + "contributions is replaced by defined_benefit_pension_input_amount, " + "the s. 234 input amount. Supply this variable to override the whole " + "total. The same input feeds adjusted income and the AA charge." ) definition_period = YEAR reference = [ + dict( + title="Finance Act 2004 s. 229", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), dict( title="Finance Act 2004 s. 233", href="https://www.legislation.gov.uk/ukpga/2004/12/section/233", ), + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), dict( title="Finance Act 2004 s. 188(3)(a)", href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", @@ -33,11 +43,18 @@ class pension_contributions_for_annual_allowance(Variable): unit = GBP def formula(person, period, parameters): - # The returned sacrifice is still an employer payment in income-tax - # law. Remove it from member contributions before the age exclusion, - # then include the full employer sacrifice once at every age. - member_contributions = person("pension_contributions", period) - person( - "salary_sacrifice_returned_to_income", period + # A supplied ordinary return is an actual member payment, while the + # NICs-only excess is not. Include full employer sacrifice separately + # once at every age, retaining the age exclusion for member payments. + defined_benefit_share = clip( + person("defined_benefit_pension_share", period), 0, 1 + ) + money_purchase = 1 - defined_benefit_share + # Net pay contributions to defined-benefit arrangements are counted + # through the defined-benefit input amount instead. + net_pay = person("employee_pension_contributions", period) + member_contributions = ( + person("pension_contributions", period) - net_pay * defined_benefit_share ) under_age_limit = ( person("age", period) @@ -47,6 +64,10 @@ def formula(person, period, parameters): ) return ( member_contributions * under_age_limit - + person("employer_pension_contributions", period) - + person("pension_contributions_via_salary_sacrifice_from_pay", period) + + ( + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) + * money_purchase + + person("defined_benefit_pension_input_amount", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py index d9afcef026..3ba60f7cd9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -17,8 +17,10 @@ class pension_contributions_relief(Variable): "above earnings (s. 191(7)); the model takes personal pension " "contributions as relief at source and employee contributions as a " "net pay arrangement. The annual allowance does not limit relief. " - "Relevant employment earnings include employer-paid taxable statutory " - "pay through employment_benefits. " + "Relevant UK earnings (s. 189(2)) are employment income, including " + "employer-paid taxable statutory pay, less allowable expenses but " + "before pension deductions, and trading profits after capital " + "allowances and the trading allowance but before losses. " "Contributions above it can be relieved here and separately attract " "the annual allowance charge (personal_pension_contributions_tax) " "under s. 227. The charge need not equal their tax relief." @@ -53,7 +55,7 @@ def formula(person, period, parameters): contributions = person("pension_contributions", period) return pension_contributions_relief_amount( contributions, - relevant_uk_earnings(person, period), + relevant_uk_earnings(person, period, hmrc), person("personal_pension_contributions", period), person("age", period), hmrc, diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py index 3334cfa70c..09184f00c9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py @@ -4,10 +4,10 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): label = "Adjusted salary sacrifice pension contributions (capped)" documentation = ( - "The actual amount of salary sacrifice pension contributions after " + "The NIC-exempt amount of salary sacrifice pension contributions after " "counting them only for people with pay and applying the cap. " - "Contributions above the cap are redirected to " - "regular employee pension contributions and subject to NI." + "Contributions above the cap remain employer pension contributions, " + "but are subject to NICs through salary_sacrifice_pension_ni_excess." ) entity = Person definition_period = YEAR @@ -28,5 +28,5 @@ def formula(person, period, parameters): return intended_ss # Cap the salary sacrifice at the limit - # Excess is redirected to regular pension contributions + # The excess is separately included in NIC earnings only. return min_(intended_ss, cap) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py index 62cb1a357e..fd8ec12eda 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py @@ -11,9 +11,9 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): "from an employment_income input) has nothing to sacrifice. The " "sacrifice is not limited to the pay that remains: an employee may " "sacrifice, for example, a whole bonus, as long as their cash pay stays " - "above the National Minimum Wage. The cap on salary sacrifice returns " - "the excess over the cap to employment income, so without this " - "condition an input sacrifice with no pay behind it would create pay." + "above the National Minimum Wage. The cap includes excess sacrifice " + "only in NIC earnings. Without this pay condition a sacrifice " + "input with no pay behind it would create a NIC earnings base." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py index 72c7ce0335..386eb3f960 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py @@ -2,44 +2,20 @@ class salary_sacrifice_returned_to_income(Variable): - label = "Amount of salary sacrifice redirected to employee pension contributions" + """Legacy supplied input, defaulting to zero; never computed by the model.""" + + label = "Salary sacrifice actually returned to ordinary income" documentation = ( - "The amount of excess salary sacrifice (above the cap) that is redirected " - "to regular employee pension contributions. This maintains total pension savings " - "while subjecting the excess to National Insurance (but not income tax, since " - "regular pension contributions receive income tax relief). " - "\n\n" - "The full excess is redirected - the employer cost increase is handled via " - "the broad-base haircut (salary_sacrifice_broad_base_haircut) which reduces " - "ALL workers' employment income by ~0.16%, not just affected workers." - "\n\n" - "Only people with pay have a salary sacrifice " - "(pension_contributions_via_salary_sacrifice_from_pay), so nothing is " - "returned to a person without pay." + "Legacy ordinary-pay/member-payment input, defaulting to zero. The " + "pension salary-sacrifice cap changes only NIC earnings, through " + "salary_sacrifice_pension_ni_excess; it does not return cash pay or " + "create a member contribution. Explicit inputs remain supported for " + "callers representing an actual return to ordinary pay and member " + "payments under the existing input convention." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" - - def formula(person, period, parameters): - intended_ss = person( - "pension_contributions_via_salary_sacrifice_from_pay", period - ) - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, no excess to redirect - if np.isinf(cap): - return 0 - - # Calculate excess above cap - full excess is redirected to regular - # employee pension contributions (no targeted haircut on the individual) - excess = max_(intended_ss - cap, 0) - - # Full excess is redirected to employment income (and then to employee - # pension contributions for income tax relief). The employer NI cost - # increase is spread across ALL workers via salary_sacrifice_broad_base_haircut. - return excess + default_value = 0 + reference = "https://www.legislation.gov.uk/ukpga/2003/1/section/308" diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py new file mode 100644 index 0000000000..3ed25308a6 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -0,0 +1,86 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import annual_allowance_for_year + +# Years of assumed history: the three years whose unused allowance is carried +# forward, and the two before them, whose unused allowance an excess in those +# three years uses first. +HISTORY_YEARS = 5 + + +class unused_pension_annual_allowance(Variable): + label = "unused pension annual allowance brought forward" + documentation = ( + "Unused annual allowance from the three previous tax years that is " + "available for this tax year (FA 2004 s. 228A(5)): the amount by which " + "each of those years' annual allowance exceeded the total pension " + "input amount, for years in which the individual was a member of a " + "registered pension scheme, less any part already used up in an " + "intervening year (earliest year first). It increases this year's " + "annual allowance (s. 228A(2)). Supply it where the history is known. " + "A value supplied or stored for a year applies to that year only, " + "since the window of years moves on: dataset projection does not copy " + "it into later years, which use this estimate. " + "Survey data records no past pension input, so from 2020-21 the " + "default assumes that in each of the five previous years the input " + "was this year's times gov.simulation.pensions.prior_year_input_ratio " + "and the incomes tested for the taper were this year's, each deflated " + "by average earnings growth, and that a person with pension input " + "this year was a member in each of those years. Each year's excess " + "over its allowance uses the unused allowance of the three years " + "before it, earliest first (s. 228A(7)). The five years leave out " + "the unused allowance an excess three years back would have used " + "first, so the estimate can carry slightly too little forward, and " + "the 2015-16 pension input period alignment rules are not applied. " + "Before 2020-21 the default is nil." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 228A", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ), + dict( + title="HMRC Pensions Tax Manual PTM055100", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100", + ), + ] + + def formula_2020(person, period, parameters): + ratio = parameters(period).gov.simulation.pensions.prior_year_input_ratio + pension_input = person("pension_contributions_for_annual_allowance", period) + adjusted_income = person("pension_annual_allowance_adjusted_income", period) + threshold_income = person("pension_annual_allowance_threshold_income", period) + # Each earlier year's amounts are this year's over the average + # earnings growth since then. + level = {0: 1.0} + for years_back in range(1, HISTORY_YEARS + 1): + growth = parameters( + period.offset(1 - years_back, YEAR) + ).gov.economic_assumptions.yoy_growth.obr.average_earnings + level[years_back] = level[years_back - 1] / (1 + growth) + unused = {} + for years_back in range(HISTORY_YEARS, 0, -1): + year = period.offset(-years_back, YEAR) + scale = level[years_back] + allowance = annual_allowance_for_year( + adjusted_income * scale, + threshold_income * scale, + parameters(year).gov.hmrc.income_tax.allowances.annual_allowance, + year.start.year, + ) + prior_input = ratio * pension_input * scale + # s. 228A(7): an excess uses the unused allowance of the three + # years before it, earliest first. + excess = max_(0, prior_input - allowance) + for earlier in (years_back + 3, years_back + 2, years_back + 1): + if earlier in unused: + used = min_(unused[earlier], excess) + unused[earlier] = unused[earlier] - used + excess = excess - used + unused[years_back] = max_(0, allowance - prior_input) + # s. 228A(4): a year counts only if the individual was a member of a + # registered pension scheme then. + return where(pension_input > 0, unused[1] + unused[2] + unused[3], 0) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py index 8d12e06ad5..c9f4403b45 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_residential_property_transactions.py @@ -17,7 +17,7 @@ def formula(household, period, parameters): price = household("main_residential_property_purchased", period) residential_purchase_qualifies_as_first_buy = household( "main_residential_property_purchased_is_first_home", period - ) & (price < price_limit) + ) & (price <= price_limit) main_residential_purchase_tax = where( residential_purchase_qualifies_as_first_buy, stamp_duty.residential.purchase.main.first.rate.calc(price), diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py new file mode 100644 index 0000000000..fbb11f0f46 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/council_tax_band_d_amount.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class council_tax_band_d_amount(Variable): + value_type = float + entity = Household + label = "Band D council tax amount" + documentation = ( + "The Band D council tax amount set by the household's local authority, " + "before discounts, exemptions or Council Tax Reduction. For England " + "and Wales this is the area figure, which includes the county, police, " + "fire, combined authority and parish or community council precepts as " + "well as the billing authority's own charge; for Scotland it excludes " + "the separately billed water and waste water charges. Fiscal 2025 and " + "2026 are loaded for all three nations, so earlier years are held flat " + "backwards from 2025.\n\n" + "Authority context: the amount is looked up from the household's " + "local_authority input, which the dataset must supply. Without it the " + "household takes the enum default (Maidstone), so a dataset that does " + "not populate local_authority charges every household Maidstone's " + "level. Microcosm UK assigns an authority to every household; the " + "Enhanced FRS does not, and reweights a national sample per authority " + "instead. Northern Ireland districts (domestic rates) and English " + "districts abolished in April 2023 have no Band D amount and return " + "zero. Downstream calibration should not consume this variable as a " + "national total until the dataset supplies local_authority; " + "policyengine-uk-data's Band D level target needs " + "PolicyEngine/policyengine-uk-data#484 (issue #483) first." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + + def formula(household, period, parameters): + la = household("local_authority", period).decode_to_str() + amounts = parameters(period).gov.local_authorities.council_tax.band_d.amounts + # Dict lookup rather than bracket indexing: the LocalAuthority enum + # also carries Northern Ireland districts (domestic rates, not council + # tax) and historical pre-2023 English districts, neither of which has + # a Band D amount, so those should return zero rather than raise. + return np.array([amounts._children.get(a, 0) for a in la], dtype=float) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py new file mode 100644 index 0000000000..9a3996f543 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax/simulated_council_tax.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class simulated_council_tax(Variable): + value_type = float + entity = Household + label = "Simulated gross council tax" + documentation = ( + "Gross annual council tax liability computed as the local authority's " + "Band D amount multiplied by the statutory band ratio. Before " + "discounts, exemptions, premiums and Council Tax Reduction." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + + def formula(household, period, parameters): + band_d = household("council_tax_band_d_amount", period) + band = household("council_tax_band", period).decode_to_str() + country = household("country", period).decode_to_str() + ratios = parameters(period).gov.local_authorities.council_tax.band_ratios + + ratio = np.ones_like(band_d, dtype=float) + for key, node in ( + ("ENGLAND", ratios.england), + ("WALES", ratios.wales), + ("SCOTLAND", ratios.scotland), + ): + table = node._children + values = np.array([float(table.get(b, 1)) for b in band], dtype=float) + ratio = np.where(country == key, values, ratio) + + # Northern Ireland uses domestic rates, not council tax. + return np.where(country == "NORTHERN_IRELAND", 0, band_d * ratio) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 1822c9f781..87eaba23a1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + deduction_per_family, + has_earned_income, +) from policyengine_uk.variables.household.demographic.highest_education import ( EducationType, ) @@ -10,8 +14,8 @@ def in_other_non_liable_family(person, period): its rent, and not paying the householder for their own occupation: all such people aged 18 or over are the applicants' non-dependants (SI 2012/2885 reg 9(2)(a), (d)-(e) and the Welsh and Scottish equivalents). - The eligibility for a non-dependant deduction and the local schemes' - aggregation both use this, so they stay on one definition.""" + The eligibility for a non-dependant deduction uses this, for the national + and local schemes alike.""" return ( ~person.benunit("council_tax_reduction_claimant_benunit", period) & ~person.benunit("liable_for_share_of_household_rent", period) @@ -98,39 +102,48 @@ def local_non_dep_deductions( individual_deduction_variable, one_deduction_for_uc_couples=True, ): - # The local schemes still charge only the non-dependants of other - # families. Non-dependants within a family's own benefit unit, now - # eligible for the national schemes, are left out here until each - # council's aggregation is brought into line. + """A claiming family's non-dependant deductions under one council's + scheme, in the shape of council_tax_reduction_non_dep_deductions. Every + eligible person in the household normally resides with each liable + applicant, so is a non-dependant of each claiming family (para 9 of each + scheme, the Default Scheme's wording): another family's adult, or an adult + in the claiming family's own benefit unit who is not its applicant, + partner or a child or young person.""" person = benunit.members - deductions = person(individual_deduction_variable, period) * ( - in_other_non_liable_family(person, period) - ) - deduction_for_benunit = benunit.max(deductions) + deductions = person(individual_deduction_variable, period) + # Only one deduction for a couple, the higher (para 30(3) of each scheme; + # Oxford para 44), except where the couple has an award of Universal + # Credit in the schemes that keep the Default Scheme's exception. Any + # other adult in a family is a non-dependant in their own right. + each_member_of_uc_couple = False if not one_deduction_for_uc_couples: - has_uc = benunit("universal_credit", period) > 0 - deduction_for_benunit = where( - has_uc, - benunit.sum(deductions), - deduction_for_benunit, - ) - is_benunit_head = benunit.members("is_benunit_head", period) - deductions_to_count = is_benunit_head * benunit.project(deduction_for_benunit) - deductions_in_household = benunit.max( - benunit.members.household.sum(deductions_to_count) - ) + each_member_of_uc_couple = benunit("universal_credit", period) > 0 + family_amount = deduction_per_family( + benunit, period, deductions, each_member_of_uc_couple + ) + # Each family's amount counted once. + counted = person("is_benunit_head", period) * benunit.project(family_amount) + in_household = benunit.max(person.household.sum(counted)) + # Members of the applicant's own family are not its non-dependants (para + # 9(2)(a)); the non-dependants in its benefit unit are. + in_unit = person("is_benefit_unit_non_dependant_for_council_tax_reduction", period) + own_in_unit = deduction_per_family( + benunit, period, deductions * in_unit, each_member_of_uc_couple + ) + non_dependants = in_household - family_amount + own_in_unit # A non-dependant of two or more jointly liable people is apportioned - # equally between them (SI 2012/2885 Sch 1 para 8(5)). + # equally between them (para 30(5) of each scheme). share = benunit("council_tax_reduction_joint_liability_share", period) + # Only a family that claims bears deductions; a non-dependant's own family + # does not. + claims = benunit("council_tax_reduction_claimant_benunit", period) # No deduction from an applicant who, or whose partner, is blind or gets a # qualifying disability benefit, whatever other claimants in the household # get (the councils' schemes follow SI 2012/2886 Sch para 30(6)). applicant_exempt = benunit( "council_tax_reduction_applicant_has_non_dep_exemption", period ) - return where( - applicant_exempt, 0, (deductions_in_household - deduction_for_benunit) * share - ) + return where(applicant_exempt, 0, claims * share * non_dependants) def normal_gross_income_non_dep_deduction( @@ -156,39 +169,51 @@ def normal_gross_income_non_dep_deduction( "dividend_income", "state_pension", ] - earned_income_components = [ - "employment_income", - "self_employment_income", - ] gross_income = add(person, period, gross_income_components) - earned_income = add(person, period, earned_income_components) - weekly_benunit_gross_income = person.benunit.sum(gross_income) / WEEKS_IN_YEAR - weekly_benunit_earned_income = person.benunit.sum(earned_income) / WEEKS_IN_YEAR - benunit_weekly_hours = person.benunit.max(person("weekly_hours", period)) + # A couple's joint income (para 30(4) of each scheme; Oxford para 44); + # another adult in the family is a non-dependant in their own right, on + # their own income. + claimant_or_partner = person("is_claimant_or_partner", period) + couple_gross_income = person.benunit.sum(gross_income * claimant_or_partner) + weekly_gross_income = ( + where(claimant_or_partner, couple_gross_income, gross_income) / WEEKS_IN_YEAR + ) + # Remunerative work is each person's own (para 10 and para 30(1)(a)). A + # couple paying one deduction pays the higher, so the working member's + # band on their joint income. in_remunerative_work = ( - benunit_weekly_hours >= ctr.non_dep_deduction.remunerative_work_hours + person("weekly_hours", period) >= ctr.non_dep_deduction.remunerative_work_hours ) weekly_deduction = where( in_remunerative_work, - ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), + ctr.non_dep_deduction.amount.calc(weekly_gross_income), ctr.non_dep_deduction.amount.calc(0), ) full_time_student = is_full_time_student_non_dep(person, period) - # No deduction for a non-dependant "who is on" Income Support, income-based - # JSA or income-related ESA: an award payable to them (Default Scheme Sch - # para 2(3)-(4)). Of a couple, only the payee is on the couple's award, and - # another member of their benefit unit's award never counts. + # No deduction for a non-dependant "who is on" Income Support, State + # Pension Credit, income-based JSA or income-related ESA (para 30(8)(a)). + # A person is on income-based JSA or income-related ESA when it is payable + # to them (Default Scheme Sch para 2(3)-(4)) and on Income Support when in + # receipt of it (para 2(1)); the model reads State Pension Credit the same + # way. Of a couple, only the payee is on the couple's award, and another + # member of their benefit unit is on it only through an award of their own. income_based_benefit = ( person("is_on_income_support", period) | person("is_on_income_based_jsa", period) | person("is_on_income_related_esa", period) - | (person.benunit("pension_credit", period) > 0) - ) - has_uc = person.benunit("universal_credit", period) > 0 - no_earned_income = weekly_benunit_earned_income <= 0 + | person("is_on_pension_credit", period) + ) + # Nor for one entitled to Universal Credit "calculated on the basis that + # the person does not have any earned income" (para 30(8)(c)): both joint + # claimants are entitled to a couple's award, and each is tested on their + # own earned income, as in the national schemes + # (council_tax_reduction_non_dep_deduction_exempt). + universal_credit_without_earned_income = person( + "is_on_universal_credit", period + ) & ~has_earned_income(person, period) exempt = ( full_time_student | (exempt_income_based_benefits & income_based_benefit) - | (exempt_uc_no_earned_income & has_uc & no_earned_income) + | (exempt_uc_no_earned_income & universal_credit_without_earned_income) ) return in_scheme_area * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index a0be20927c..2f5010c9bf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -26,7 +26,13 @@ class council_tax_reduction_applicable_income(Variable): "awards (Child Benefit, Income Support, income-based JSA, " "income-related ESA, Universal Credit, tax credits, Pension Credit) " "and its children belong to its claimant and partner, not to the " - "head, so none of them counts." + "head, so none of them counts. Industrial injuries disablement " + "benefit, severe disablement allowance and incapacity benefit count " + "in every scheme, for the applicant and partner only, not for " + "children or young persons. War pensions and Armed Forces " + "Compensation Scheme payments are not counted: Scotland disregards or " + "omits them, and English and Welsh councils may relieve them beyond " + "the prescribed £10 a week, so the model takes the full disregard." ) definition_period = YEAR unit = GBP @@ -49,6 +55,15 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/5", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/7", "https://www.legislation.gov.uk/ssi/2021/249/regulation/36", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/1", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/39", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/54", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/34", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/17", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/1", + "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/2", + "https://www.legislation.gov.uk/ukpga/1992/14/section/13A", + "https://www.newham.gov.uk/downloads/file/209/discretionary-council-tax-reductions-for-individual-council-tax-payers", ] def formula(benunit, period, parameters): @@ -104,6 +119,57 @@ def formula(benunit, period, parameters): "statutory_maternity_pay", "ssmg", ] + # Every scheme the model runs counts these three benefits. At pension + # age they are social security benefits that the income lists do not + # except: SI 2012/2885 Sch 1 para 16(1)(j), the default scheme SI + # 2012/2886 Sch para 39(1)(j), WSI 2013/3029 Sch 1 para 10(1)(j) and + # SSI 2012/319 reg 27(1)(j). At working age England and Wales count + # gross income (SI 2012/2886 Sch para 54(1); WSI 2013/3029 Sch 6 para + # 17(1)) less disregards that do not list them (their Schs 8 and 9), + # and Scotland's closed list names them (SSI 2021/249 reg 57(1)(b)(ix) + # to (xi)). Each scheme leaves out IIDB's constant attendance and + # exceptionally severe disablement increases (SSCBA 1992 ss.104 and + # 105), by name or as "attendance allowance" (Sch 8 and Sch 9 para + # 14), but iidb is the whole reported payment and cannot separate + # them. Only the applicant's and partner's count (Sch 1 para 11; SSI + # 2021/249 reg 36(1)): the Council Tax Reduction applicant and partner, + # which is the household head alone where the head applies alone, and + # never the dependants that the wider mask above still reaches. + applicant_and_partner_benefits = ["iidb", "sda", "incapacity_benefit"] + applicant_and_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + # War disablement pensions, war widow's or widower's pensions and + # Armed Forces Compensation Scheme payments (afcs, war_widows_pension). + # Scotland counts none of them: SSI 2012/319 Sch 3 para 1 disregards + # each in full, and SSI 2021/249 reg 57(1), a closed list, does not + # name them. England and Wales prescribe a £10 a week disregard (SI + # 2012/2885 Sch 5 para 1; SI 2012/2886 Sch 6 para 1 and Sch 8 para 20; + # WSI 2013/3029 Sch 4 para 1 and Sch 9 para 20), which a council may + # exceed: Wales makes it a minimum for war disablement and war widow's + # or widower's pensions (WSI 2013/3029 reg 34(5)), an English scheme + # may make provision equivalent to the council tax benefit rules (LGFA + # 1992 Sch 1A para 2(11)), and any council may reduce council tax + # further (s.13A(1)(c)). The model takes the full disregard, as + # Housing Benefit does for local schemes under SSAA 1992 s.134(8)(a). + # The councils whose schemes the model reads mostly go further: + # Westminster's working-age and pensioner schemes disregard war + # pensions in full, and Oxford's Appendix 5 ignores war pensions for + # applicants without Universal Credit. Newham's scheme keeps the £10 + # (Sch 6 para 1, Sch 8 para 19), but since 2013 it adds automatically + # the reduction a full disregard of war compensation payments would + # give (its s.13A(1)(c) policy, para 2.1). Kingston upon Thames's + # schedules keep the £10 (Sch 6 para 1, Sch 8 para 20) while its para + # 36(6) speaks of "the higher amount of full disregard" of the same + # payments. Merton's Sch 8 para 20 disregards them "in full" but + # "subject to paragraph 40", whose £20 a week limit, read literally, + # caps that at working age. These are approximations of the councils' + # texts. The full disregard also over-disregards guaranteed income + # payments in Wales, which reg 34(5) does not cover, and wherever a + # scheme keeps the prescribed £10 without further relief; afcs does + # not separate guaranteed income payments from war disablement + # pensions, nor either from the constant attendance and severe + # disablement additions that every scheme disregards in full. income_components = [ "employment_income", "self_employment_income", @@ -137,6 +203,9 @@ def formula(benunit, period, parameters): personal_benefit_income = add_for_members( benunit, period, personal_benefits, members ) + personal_benefit_income += add_for_members( + benunit, period, applicant_and_partner_benefits, applicant_and_partner + ) credits = applicant_family * add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefit_income + credits + benefits diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index b05ef2f086..6a8608c938 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -17,7 +17,17 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): "a boarder's or lodger's adult son. Someone jointly and severally " "liable for the council tax with the applicant (a joint tenant or " "other sharer of the rent) and a boarder or lodger who pays the " - "applicant are not non-dependants." + "applicant are not non-dependants. Those exclusions belong to the " + "person, whatever family they are in: a joint tenant liable for the " + "rent who is not their benefit unit's claimant or partner, and a " + "person outside the household head's family who pays the " + "householder for their own board or lodging, is nobody's " + "non-dependant. A member of the household head's own family who pays " + "board is taken to be a close relative of the head, so remains a " + "non-dependant. The model has no relationship input, so a close " + "relative in another family who pays board or lodging, or someone " + "paying on a non-commercial basis, is treated as excluded, although " + "the schemes make them non-dependants." ) definition_period = YEAR reference = ( @@ -37,4 +47,28 @@ def formula(person, period, parameters): in_unit = person( "is_benefit_unit_non_dependant_for_council_tax_reduction", period ) - return (person("age", period) >= 18) & (other_family | in_unit) + # Reg 9(2)(d)-(e) exclude the person, not only their family. A joint + # tenant (the same interest in the dwelling, LGFA 1992 s.6) is + # jointly and severally liable with the applicant, even one who is + # not their benefit unit's claimant or partner; the applicants and + # partners of liable families are never in_unit. Anyone who pays the + # householder for their own board or lodging is liable to make + # payments on a commercial basis, even where the rest of their family + # pays nothing, unless they are a close relative of the applicant or + # partner or the agreement is not commercial (reg 9(3)(a)). The model + # has no relationship input: a member of the household head's own + # family who pays board is taken to be a close relative, and anyone + # else who pays as excluded. + pays_householder = ( + add(person, period, ["rent_paid_as_boarder", "rent_paid_as_lodger"]) > 0 + ) + in_head_family = person.benunit("benunit_contains_household_head", period) + joint_tenant = person("is_liable_for_household_rent", period) & ~person( + "is_claimant_or_partner", period + ) + personally_excluded = joint_tenant | (pays_householder & ~in_head_family) + return ( + (person("age", period) >= 18) + & (other_family | in_unit) + & ~personally_excluded + ) diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py index feefaa2813..7efecd22ff 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction_non_dep_deductions.py @@ -8,12 +8,26 @@ class westminster_council_tax_reduction_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "Westminster CTR non-dependent deductions" + documentation = ( + "Westminster's working-age scheme is based on the Council Tax " + "Reduction Schemes (Default Scheme) (England) Regulations 2012, and " + "its published summary sets out only the scale (Appendix A). The " + "Default Scheme's para 30(3) makes one deduction for a couple, the " + "higher, other than where there is an award of Universal Credit, so " + "each member of a non-dependant couple with Universal Credit is " + "deducted for, as in Merton's and Kingston upon Thames's texts." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.westminster.gov.uk/sites/default/files/media/documents/council-tax-support-scheme-for-2026-27.pdf", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/30", + ) def formula(benunit, period, parameters): return local_non_dep_deductions( benunit, period, "westminster_council_tax_reduction_individual_non_dep_deduction", + one_deduction_for_uc_couples=False, ) diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py index 8826fc1a5b..83dd4aed74 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_overlap.py @@ -10,25 +10,51 @@ class carer_support_payment_pre_overlap(Variable): "to before regulation 16(2) of the Carer Support Payment Regulations " "reduces it by an overlapping benefit. A carer whose payment is " "reduced to £0 keeps this underlying entitlement. " - "carer_support_payment is the amount given." + "carer_support_payment is the amount given. The earnings condition uses " + "the existing annual Carer's Allowance net-earnings reader, spread " + "evenly over 52 weeks. This approximates constant earnings; it cannot " + "resolve the award-week or, from 16 November 2025, preceding-award-week " + "assessment for varying earnings. The reader's standard-code PAYE and " + "unrepresented care-charge/disregard assumptions also apply." ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/asp/2018/9/part/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/3", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/14", + "https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4", ) def formula(person, period, parameters): in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" csp_in_effect = period.start.year >= 2025 + if not csp_in_effect: + return person.empty_array() csp = parameters(period).gov.social_security_scotland.carer_support_payment + meets_age_condition = person("age", period) >= csp.min_age weekly_care_hours = person("care_hours", period) meets_hours = weekly_care_hours >= csp.min_hours receives_ca = person("carers_allowance_reported", period) > 0 would_claim = person("would_claim_carers_allowance", period) + earnings = person("carers_allowance_earnings", period) + # Schedule 2 paragraph 4 disregards an adverse fraction of a penny. + # The first excluded weekly amount is the limit plus one penny. + # Compare annual amounts in the reader's dtype so the encoded exact + # penny boundary is not admitted through scalar/array rounding. + first_excluded_annual_earnings = np.asarray( + (csp.earnings_limit + csp.earnings_rounding_precision) * WEEKS_IN_YEAR, + dtype=earnings.dtype, + ) + meets_earnings = earnings < first_excluded_annual_earnings eligible = ( - in_scotland & csp_in_effect & (meets_hours | receives_ca) & would_claim + in_scotland + & csp_in_effect + & (meets_hours | receives_ca) + & meets_earnings + & meets_age_condition + & would_claim ) return eligible * csp.rate * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py index b0da36b0bf..f7aba9e1ba 100644 --- a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py +++ b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py @@ -4,8 +4,9 @@ class employee_pension_contributions(Variable): label = "employee pension contributions" documentation = ( - "Total employee pension contributions including reported contributions " - "and any excess salary sacrifice redirected to regular pension contributions." + "Actual reported employee pension contributions, plus any explicitly " + "supplied legacy return to ordinary pay/member payments. The " + "NIC-only salary-sacrifice excess is not a member contribution." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/input/defined_benefit_pension_share.py b/policyengine_uk/variables/input/defined_benefit_pension_share.py new file mode 100644 index 0000000000..a5bd1f4f29 --- /dev/null +++ b/policyengine_uk/variables/input/defined_benefit_pension_share.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_share(Variable): + value_type = float + entity = Person + label = "Share of workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The share, from 0 to 1, of the person's workplace pension " + "contributions that is paid into defined-benefit arrangements: " + "employer contributions, employee contributions under a net pay " + "arrangement and salary sacrifice. Personal pension contributions " + "(relief at source) are money purchase. The pension input amount of " + "a defined-benefit arrangement is the increase in the value of the " + "member's rights (FA 2004 s. 234), not the contributions paid, so the " + "annual allowance counts defined_benefit_pension_input_amount in " + "place of this share of the contributions. Data that records scheme " + "type supplies it. The default of nil treats every contribution as " + "money purchase." + ) + definition_period = YEAR + unit = "/1" + reference = [ + dict( + title="Finance Act 2004 s. 229 (total pension input amount)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), + dict( + title="Finance Act 2004 s. 234 (defined benefits arrangements)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + ] diff --git a/policyengine_uk/variables/input/is_full_year_category_a_director.py b/policyengine_uk/variables/input/is_full_year_category_a_director.py new file mode 100644 index 0000000000..55e57a5b0a --- /dev/null +++ b/policyengine_uk/variables/input/is_full_year_category_a_director.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_full_year_category_a_director(Variable): + label = "Full-year category A director, one employment (employee NI, 2022–23 only)" + documentation = ( + "For employee NI only, explicitly declares directorship throughout the tax year, one employment " + "and category A throughout. Only model year 2022 uses this fact. It does " + "not describe appointment years, other categories or payroll timing." + ) + entity = Person + definition_period = YEAR + value_type = bool + default_value = False + reference = [ + "https://www.gov.uk/employee-directors", + "https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2022-to-2023", + ] diff --git a/policyengine_uk/variables/input/pip_dl_category.py b/policyengine_uk/variables/input/pip_dl_category.py index 45a933e786..b09f63d022 100644 --- a/policyengine_uk/variables/input/pip_dl_category.py +++ b/policyengine_uk/variables/input/pip_dl_category.py @@ -1,12 +1,17 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pip import pip_category from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory class pip_dl_category(Variable): label = "PIP (daily living) category" - documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline." + documentation = "If you receive the daily living component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from the person's latest `pip_dl_points` for this or an earlier year against the points thresholds in `gov.dwp.pip.daily_living.points_thresholds`. A category supplied for an earlier year carries forward instead, unless the person has points for a later year than that category." entity = Person definition_period = YEAR value_type = Enum possible_values = PIPCategory default_value = PIPCategory.NONE + + def formula(person, period, parameters): + thresholds = parameters(period).gov.dwp.pip.daily_living.points_thresholds + return pip_category(person, period, thresholds, "dl") diff --git a/policyengine_uk/variables/input/pip_dl_points.py b/policyengine_uk/variables/input/pip_dl_points.py new file mode 100644 index 0000000000..175a6253a3 --- /dev/null +++ b/policyengine_uk/variables/input/pip_dl_points.py @@ -0,0 +1,10 @@ +from policyengine_uk.model_api import * + + +class pip_dl_points(Variable): + label = "PIP daily living points" + documentation = "Total descriptor points scored across the ten Schedule 1 Part 2 daily living activities. Used to derive `pip_dl_category` when the category is not provided as input. The default of -1 means the person was not assessed; 0 is an assessment that scored no points." + entity = Person + definition_period = YEAR + value_type = int + default_value = -1 diff --git a/policyengine_uk/variables/input/pip_m_category.py b/policyengine_uk/variables/input/pip_m_category.py index e523d95ef9..e6ea92e085 100644 --- a/policyengine_uk/variables/input/pip_m_category.py +++ b/policyengine_uk/variables/input/pip_m_category.py @@ -1,12 +1,17 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pip import pip_category from policyengine_uk.variables.gov.dwp.pip.pip import PIPCategory class pip_m_category(Variable): label = "PIP (mobility) category" - documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline." + documentation = "If you receive the mobility component of the Personal Independence Payment, you will be in one of the following categories: Standard or Enhanced. If not, select None. Survey reported amounts should be converted to this category in the data pipeline. When not provided as input for the year, the category is derived from the person's latest `pip_m_points` for this or an earlier year against the points thresholds in `gov.dwp.pip.mobility.points_thresholds`. A category supplied for an earlier year carries forward instead, unless the person has points for a later year than that category." entity = Person definition_period = YEAR value_type = Enum possible_values = PIPCategory default_value = PIPCategory.NONE + + def formula(person, period, parameters): + thresholds = parameters(period).gov.dwp.pip.mobility.points_thresholds + return pip_category(person, period, thresholds, "m") diff --git a/policyengine_uk/variables/input/pip_m_points.py b/policyengine_uk/variables/input/pip_m_points.py new file mode 100644 index 0000000000..0856575bb1 --- /dev/null +++ b/policyengine_uk/variables/input/pip_m_points.py @@ -0,0 +1,10 @@ +from policyengine_uk.model_api import * + + +class pip_m_points(Variable): + label = "PIP mobility points" + documentation = "Total descriptor points scored across the two Schedule 1 Part 3 mobility activities. Used to derive `pip_m_category` when the category is not provided as input. The default of -1 means the person was not assessed; 0 is an assessment that scored no points." + entity = Person + definition_period = YEAR + value_type = int + default_value = -1 diff --git a/policyengine_uk/variables/input/unused_pension_annual_allowance.py b/policyengine_uk/variables/input/unused_pension_annual_allowance.py deleted file mode 100644 index 59b2549769..0000000000 --- a/policyengine_uk/variables/input/unused_pension_annual_allowance.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * - - -class unused_pension_annual_allowance(Variable): - label = "unused pension annual allowance brought forward" - documentation = ( - "Unused annual allowance from the three previous tax years that is " - "available for this tax year (FA 2004 s. 228A(5)): the amount by which " - "each of those years' annual allowance exceeded the total pension " - "input amount, for years in which the individual was a member of a " - "registered pension scheme, less any part already used up in an " - "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Survey data does not record past " - "pension input amounts, so this defaults to nil." - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - reference = dict( - title="Finance Act 2004 s. 228A", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", - ) diff --git a/pyproject.toml b/pyproject.toml index d48ccc67f1..7ca01757bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.141.5" +version = "2.146.2" description = "PolicyEngine tax and benefit system for the UK." readme = "README.md" authors = [ diff --git a/scripts/fit_bus_cap_savings.py b/scripts/fit_bus_cap_savings.py new file mode 100644 index 0000000000..97e7706c2d --- /dev/null +++ b/scripts/fit_bus_cap_savings.py @@ -0,0 +1,161 @@ +"""Fit the table of uncapped single bus fares to public facts. + +Prints the three-band table for gov.dft.bus.uncapped_single_fares (the share +of single tickets in each band and its mean uncapped fare, at FY2024-25 +prices) that best matches the facts below, and the value of each fact under +it. Fares move between years with the engine's OBR CPI index, as the +parameter's uprating does. See docs/engineering/bus-fares.md. + +Run with ``uv run python scripts/fit_bus_cap_savings.py``. +""" + +import numpy as np + +from policyengine_uk.system import system + +SINGLE_SHARE = 0.70 # gov.dft.bus.default_single_fare_share +# DfT BUS01a journeys less BUS01c concessionary journeys, England outside London. +FARE_PAYING_2023_24 = 1_780_912_900.016 - 499_730_082.115 +FARE_PAYING_2024_25 = 1_850_509_887.261 - 519_107_608.594 +# The benchmark yield at the December 2024 BUS0415a level, just before the +# January 2025 step: 1.554716967657 * 185.9 / 190.2282. +YIELD_DECEMBER_2024 = 1.554716967657 * 185.9 / 190.2282 + +# Each fact: target and tolerance, relative (a share of the target) or absolute. +FACTS = { + # BUS05bi fare cap support, 2023-24, a full year at GBP 2 (GBP m). + "grant_2023_24": (278.951028, 0.10, "relative"), + # Part of the December 2024 to March 2025 BUS0415a step outside London + # (+11.0%; neighbouring quarters moved 0.1% to 2.3%) due to the cap rising + # from GBP 2 to GBP 3, as a share of the yield. + "january_2025_step": (0.10, 0.01, "absolute"), + # Funding of "over GBP 150 million" for the GBP 3 cap in 2025 (GBP m). + "cap_3_in_2025": (150, 0.20, "relative"), + # GBP 400 million backing the GBP 2 cap in 2027 (GBP m), read as GBP 2 + # against no cap: the GBP 3 scheme is funded only to December 2026 and the + # GBP 2 scheme replaces it. Read as GBP 2 against a continued GBP 3 cap, the + # fitted table gives about GBP 247m, 38% below (see UNFITTED below). + "cap_2_in_2027": (400, 0.20, "relative"), + # Frontier Economics and SYSTRA for DfT, Table 6: all single tickets + # averaged GBP 2.15 before the cap; those above GBP 2 averaged GBP 2.73, + # and GBP 0.32 / (2.73 - 2.00) puts their share near 0.44. Used for shape, + # at face value. + "mean_single_fare": (2.15, 0.10, "absolute"), + "share_above_2": (0.44, 0.05, "absolute"), + "mean_above_2": (2.73, 0.10, "absolute"), +} + + +# Published figures the fit reports but does not target (GBP m). +UNFITTED = { + # BUS05bi fare cap support for 2024-25, the benchmark year: GBP 2 for 270 + # days, then GBP 3. Grant per fare-paying boarding rose 78% on 2023-24 + # while fares rose 3% to 7% and the cap rose for the last quarter, so the + # figure may hold more than that year's forgone fares (timing or coverage + # of payments, not checked). The 2023-24 grant and the two announced + # figures all put a full year at GBP 2 near GBP 0.3bn, not GBP 0.6bn. + "grant_2024_25": 515.693217, + # The GBP 400m read the other way, as GBP 2 against a continued GBP 3 cap. + "cap_2_against_3_in_2027": 400, +} + + +def price_ratios(): + cpi = system.parameters.gov.economic_assumptions.indices.obr.consumer_price_index + base = cpi("2024-01-01") + return {year: cpi(f"{year}-01-01") / base for year in (2023, 2025, 2027)} + + +def unfitted(shares, fares, ratios): + in_force_2024_25 = sum( + s * (f - (270 * min(f, 2) + 95 * min(f, 3)) / 365) + for s, f in zip(shares, fares) + ) + return { + "grant_2024_25": SINGLE_SHARE * FARE_PAYING_2024_25 / 1e6 * in_force_2024_25, + "cap_2_against_3_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * ( + saving(shares, fares, 2, ratios[2027]) + - saving(shares, fares, 3, ratios[2027]) + ), + } + + +def saving(shares, fares, cap, ratio=1.0): + """Mean saving per single ticket from a cap: sum of share * (fare - cap)+.""" + return sum(s * np.maximum(ratio * f - cap, 0) for s, f in zip(shares, fares)) + + +def facts(shares, fares, ratios): + low, middle, high = shares + above = middle + high + return { + "grant_2023_24": SINGLE_SHARE + * FARE_PAYING_2023_24 + / 1e6 + * saving(shares, fares, 2, ratios[2023]), + "january_2025_step": SINGLE_SHARE + * (saving(shares, fares, 2) - saving(shares, fares, 3)) + / YIELD_DECEMBER_2024, + "cap_3_in_2025": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 3, ratios[2025]), + "cap_2_in_2027": SINGLE_SHARE + * FARE_PAYING_2024_25 + / 1e6 + * saving(shares, fares, 2, ratios[2027]), + "mean_single_fare": sum(s * f for s, f in zip(shares, fares)), + "share_above_2": above, + "mean_above_2": (middle * fares[1] + high * fares[2]) / above, + } + + +def loss(values): + total = 0 + for name, (target, tolerance, kind) in FACTS.items(): + gap = values[name] / target - 1 if kind == "relative" else values[name] - target + total = total + (gap / tolerance) ** 2 + return total + + +def fit(): + """Grid search at one point of share and one penny of fare. Each band's + mean sits at least 10% above its lower edge (GBP 2.20 and GBP 3.30), and + the low band's fare closes the Table 6 mean of GBP 2.15.""" + ratios = price_ratios() + middle_fare, high_fare = np.meshgrid( + np.arange(220, 300) / 100, np.arange(330, 600) / 100, indexing="ij" + ) + best = (np.inf, None) + for middle in np.arange(10, 61) / 100: + for high in np.arange(2, 41) / 100: + low = round(1 - middle - high, 2) + if low <= 0: + continue + low_fare = (2.15 - middle * middle_fare - high * high_fare) / low + low_fare = np.round(low_fare, 2) + valid = (low_fare > 0) & (low_fare <= 2) + shares = (low, middle, high) + values = facts(shares, (low_fare, middle_fare, high_fare), ratios) + score = np.where(valid, loss(values), np.inf) + index = np.unravel_index(np.argmin(score), score.shape) + if score[index] < best[0]: + fares = (low_fare[index], middle_fare[index], high_fare[index]) + best = (score[index], (shares, fares)) + return best + + +if __name__ == "__main__": + score, (shares, fares) = fit() + print(f"loss {score:.3f}") + for band, share, fare in zip(("LOW", "MIDDLE", "HIGH"), shares, fares): + print(f"{band:7s} share {share:.2f} fare GBP {fare:.2f}") + values = facts(shares, fares, price_ratios()) + for name, (target, tolerance, kind) in FACTS.items(): + print(f"{name:18s} {values[name]:9.4f} target {target}") + checks = unfitted(shares, fares, price_ratios()) + for name, published in UNFITTED.items(): + print(f"{name:24s} {checks[name]:9.4f} published {published}, not fitted")